diff --git a/packages/ui/src/common/Modal.tsx b/packages/ui/src/common/Modal.tsx
index a4149958..998121f8 100644
--- a/packages/ui/src/common/Modal.tsx
+++ b/packages/ui/src/common/Modal.tsx
@@ -36,7 +36,7 @@ export type ModalProps = {
onAfterOpen?: () => void;
children?: ReactNode;
title: string;
- subtitle?: string;
+ subtitle?: ReactNode;
};
// Using react-modal's built-in styling system instead of emotion css for modal configuration
@@ -73,6 +73,8 @@ const headerStyle = (theme: Theme) => css`
border-bottom: 1px solid ${theme.colors.border_subtle};
background: ${theme.colors.white};
box-shadow: 0px 1px 6px 0px #00000026;
+ position: relative;
+ z-index: 1;
`;
const bodyStyle = css`
@@ -117,7 +119,7 @@ const ModalComponent = ({ children, setIsOpen, isOpen, onAfterOpen, title, subti
{title}
- {subtitle &&
{subtitle}
}
+ {subtitle &&
{subtitle}
}
diff --git a/packages/ui/src/theme/emotion/schemaNodeStyles.ts b/packages/ui/src/theme/emotion/schemaNodeStyles.ts
index e8830f61..e3bc69b3 100644
--- a/packages/ui/src/theme/emotion/schemaNodeStyles.ts
+++ b/packages/ui/src/theme/emotion/schemaNodeStyles.ts
@@ -22,13 +22,21 @@
import { css } from '@emotion/react';
import type { Theme } from '../';
-export const fieldRowStyles = css`
+export const fieldRowStyles = (theme: Theme, isForeignKey: boolean, isEven: boolean, isHighlighted: boolean = false) => css`
padding: 12px 12px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s;
position: relative;
+ background-color: ${isHighlighted ? theme.colors.secondary_1 : isEven ? '#e5edf3' : 'transparent'};
+ border-block: 1.5px solid ${isHighlighted ? theme.colors.secondary_dark : isEven ? '#d4dce2' : 'transparent'};
+ ${isForeignKey ? 'cursor: pointer;' : ''}
+
+ &:hover {
+ background-color: ${isForeignKey ? theme.colors.secondary_1 : theme.colors.grey_3};
+ border-block: 1.5px solid ${isForeignKey ? theme.colors.secondary_dark : theme.colors.grey_4};
+ }
`;
export const fieldContentStyles = css`
@@ -61,16 +69,18 @@ export const dataTypeBadgeStyles = (theme: Theme) => css`
}
`;
-export const nodeContainerStyles = css`
+export const nodeContainerStyles = (isInactive: boolean = false) => css`
background: white;
border: 1px solid black;
border-radius: 8px;
box-shadow:
- 0 10px 20px -3px rgba(0, 0, 0, 0.30),
+ 0 10px 20px -3px rgba(0, 0, 0, 0.3),
0 4px 10px -2px rgba(0, 0, 0, 0.35);
min-width: 280px;
max-width: 350px;
overflow: hidden;
+ transition: opacity 0.2s;
+ ${isInactive ? 'opacity: 0.4;' : ''}
`;
export const nodeHeaderStyles = (theme: Theme) => css`
@@ -102,10 +112,6 @@ export const nodeSubtitleTextStyle = css`
export const fieldsListStyles = css`
background: #f8fafc;
overflow-y: auto;
- & > div:nth-child(even) {
- background-color: #e5edf3;
- border-block: 1.5px solid #d4dce2;
- }
`;
export const fieldNameContainerStyles = css`
diff --git a/packages/ui/src/theme/icons/OneCardinalityMarker.tsx b/packages/ui/src/theme/icons/OneCardinalityMarker.tsx
index c8fb74a1..603bcf58 100644
--- a/packages/ui/src/theme/icons/OneCardinalityMarker.tsx
+++ b/packages/ui/src/theme/icons/OneCardinalityMarker.tsx
@@ -22,12 +22,14 @@
/** @jsxImportSource @emotion/react */
export const ONE_CARDINALITY_MARKER_ID = 'one-cardinality-marker';
+export const ONE_CARDINALITY_MARKER_ACTIVE_ID = 'one-cardinality-marker-active';
type OneCardinalityMarkerProps = {
color?: string;
+ activeColor: string;
};
-const OneCardinalityMarker = ({ color = '#374151' }: OneCardinalityMarkerProps) => {
+const OneCardinalityMarker = ({ color = '#374151', activeColor }: OneCardinalityMarkerProps) => {
return (
);
diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx
new file mode 100644
index 00000000..b3b293b7
--- /dev/null
+++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/ActiveRelationshipContext.tsx
@@ -0,0 +1,120 @@
+/*
+ *
+ * Copyright (c) 2026 The Ontario Institute for Cancer Research. All rights reserved
+ *
+ * This program and the accompanying materials are made available under the terms of
+ * the GNU Affero General Public License v3.0. You should have received a copy of the
+ * GNU Affero General Public License along with this program.
+ * If not, see .
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+ * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+ * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ */
+
+import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
+import { traceChain, type RelationshipMap } from './diagramUtils';
+
+type ActiveRelationshipState = {
+ edgeIds: Set;
+ fieldKeys: Set;
+ schemaChain: string[];
+} | null;
+
+type ActiveRelationshipContextValue = {
+ activeEdgeIds: Set | null;
+ activeFieldKeys: Set | null;
+ activeSchemaNames: Set | null;
+ activeSchemaChain: string[] | null;
+ activateRelationship: (fkIndex: number, mappingIndex?: number) => void;
+ deactivateRelationship: () => void;
+ relationshipMap: RelationshipMap;
+ isFieldInActiveRelationship: (schemaName: string, fieldName: string) => boolean;
+};
+
+const ActiveRelationshipContext = createContext(null);
+
+type ActiveRelationshipProviderProps = {
+ relationshipMap: RelationshipMap;
+ children: ReactNode;
+};
+
+/**
+ * Provides active relationship state and actions to the ERD component tree.
+ * Wraps children with context that tracks which FK chain is currently highlighted,
+ * and exposes methods to activate/deactivate highlighting via traceChain.
+ *
+ * @param {RelationshipMap} relationshipMap — The FK adjacency graph used for chain tracing
+ * @param {ReactNode} children — Child components that can consume the active relationship context
+ */
+export function ActiveRelationshipProvider({ relationshipMap, children }: ActiveRelationshipProviderProps) {
+ const [activeState, setActiveState] = useState(null);
+
+ const activateRelationship = useCallback(
+ (fkIndex: number, mappingIndex?: number) => {
+ const result = traceChain(fkIndex, relationshipMap, mappingIndex);
+ setActiveState(result);
+ },
+ [relationshipMap],
+ );
+
+ const deactivateRelationship = useCallback(() => {
+ setActiveState(null);
+ }, []);
+
+ const isFieldInActiveRelationship = useCallback(
+ (schemaName: string, fieldName: string): boolean => {
+ if (!activeState) return false;
+ return activeState.fieldKeys.has(`${schemaName}::${fieldName}`);
+ },
+ [activeState],
+ );
+
+ const activeSchemaNames = useMemo(() => {
+ if (!activeState) return null;
+ const names = new Set();
+ for (const key of activeState.fieldKeys) {
+ const schemaName = key.split('::')[0];
+ if (schemaName) names.add(schemaName);
+ }
+ return names;
+ }, [activeState]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Consumes the active relationship context. Must be called from a component
+ * that is a descendant of ActiveRelationshipProvider.
+ *
+ * @returns {ActiveRelationshipContextValue} The current active relationship state and actions
+ */
+export function useActiveRelationship(): ActiveRelationshipContextValue {
+ const context = useContext(ActiveRelationshipContext);
+ if (!context) {
+ throw new Error('useActiveRelationship must be used within an ActiveRelationshipProvider');
+ }
+ return context;
+}
diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx
index 99657009..e951f812 100644
--- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx
+++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/EntityRelationshipDiagram.tsx
@@ -20,18 +20,29 @@
*/
/** @jsxImportSource @emotion/react */
+import { css } from '@emotion/react';
+import { type Theme, useThemeContext } from '../../theme';
import type { Dictionary } from '@overture-stack/lectern-dictionary';
+import { useCallback, useEffect } from 'react';
import ReactFlow, {
Background,
BackgroundVariant,
Controls,
useEdgesState,
useNodesState,
+ type Edge,
type NodeTypes,
} from 'reactflow';
import 'reactflow/dist/style.css';
import OneCardinalityMarker from '../../theme/icons/OneCardinalityMarker';
-import { getEdgesForDictionary, getNodesForDictionary, type SchemaNodeLayout } from './diagramUtils';
+import {
+ getEdgesFromMap,
+ getEdgesWithHighlight,
+ getNodesForDictionary,
+ type RelationshipEdgeData,
+ type SchemaNodeLayout,
+} from './diagramUtils';
+import { useActiveRelationship } from './ActiveRelationshipContext';
import { SchemaNode } from './SchemaNode';
const nodeTypes: NodeTypes = {
@@ -43,8 +54,37 @@ type EntityRelationshipDiagramProps = {
layout?: Partial;
};
+const edgeStyles = (theme: Theme) => css`
+ .react-flow__edge {
+ cursor: pointer;
+ }
+ .react-flow__edge-path {
+ stroke: ${theme.colors.black};
+ stroke-width: 2;
+ }
+ .react-flow__edge:hover .react-flow__edge-path {
+ stroke: ${theme.colors.secondary_dark};
+ }
+
+ .react-flow__edge.edge-active .react-flow__edge-path {
+ stroke: ${theme.colors.secondary_dark};
+ stroke-width: 3;
+ }
+
+ .react-flow__edge.edge-inactive .react-flow__edge-path {
+ stroke: ${theme.colors.grey_5};
+ stroke-width: 1.5;
+ opacity: 0.9;
+ }
+
+ .react-flow__edge.edge-inactive .react-flow__edge-path:hover {
+ stroke: ${theme.colors.grey_4};
+ }
+`;
+
/**
* 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} layout — Optional overrides for the grid layout of schema nodes.
@@ -52,29 +92,53 @@ type EntityRelationshipDiagramProps = {
* 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)
*/
-export function EntityRelationshipDiagram({ dictionary, layout }: EntityRelationshipDiagramProps) {
+export function EntityRelationshipDiagramContent({ dictionary, layout }: EntityRelationshipDiagramProps) {
const [nodes, , onNodesChange] = useNodesState(getNodesForDictionary(dictionary, layout));
- const [edges, , onEdgesChange] = useEdgesState(getEdgesForDictionary(dictionary));
+ const { activeEdgeIds, activateRelationship, deactivateRelationship, relationshipMap } = useActiveRelationship();
+ const [edges, setEdges, onEdgesChange] = useEdgesState(getEdgesFromMap(relationshipMap));
+ const theme = useThemeContext();
+
+ useEffect(() => {
+ setEdges((currentEdges) => getEdgesWithHighlight(currentEdges, activeEdgeIds, theme.colors.secondary_dark));
+ }, [activeEdgeIds, setEdges]);
+
+ const onEdgeClick = useCallback(
+ (_event: React.MouseEvent, edge: Edge) => {
+ const edgeData = edge.data as RelationshipEdgeData | undefined;
+ if (edgeData?.fkIndex !== undefined) {
+ activateRelationship(edgeData.fkIndex, edgeData.mappingIndex);
+ }
+ },
+ [activateRelationship],
+ );
+
+ const onPaneClick = useCallback(() => {
+ deactivateRelationship();
+ }, [deactivateRelationship]);
return (
<>
-
-
-
-
-
+
+
+
+
+
+
+
>
);
}
diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx
index 036609f2..d6c5325a 100644
--- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx
+++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/SchemaNode.tsx
@@ -40,13 +40,17 @@ import {
targetHandleStyles,
} from '../../theme/emotion/schemaNodeStyles';
import { createFieldHandleId } from './diagramUtils';
+import { useActiveRelationship } from './ActiveRelationshipContext';
export function SchemaNode(props: { data: Schema }) {
const { data: schema } = props;
const theme = useThemeContext();
+ const { activateRelationship, isFieldInActiveRelationship, activeSchemaNames, relationshipMap } =
+ useActiveRelationship();
+ const isInactive = activeSchemaNames !== null && !activeSchemaNames.has(schema.name);
return (
-
+
{schema.name}
Schema
@@ -59,11 +63,30 @@ export function SchemaNode(props: { data: Schema }) {
schema.restrictions?.foreignKey?.some((fk) =>
fk.mappings.some((mapping) => mapping.local === field.name),
) || false;
-
+ const isEvenRow = index % 2 === 1;
const valueType = field.isArray ? `${field.valueType}[]` : field.valueType;
+ const isHighlighted = isFieldInActiveRelationship(schema.name, field.name);
+
+ const handleFieldClick =
+ isForeignKey ?
+ () => {
+ const fieldKey = `${schema.name}::${field.name}`;
+ const fkIndices = relationshipMap.fieldKeyToFkIndices.get(fieldKey);
+ if (fkIndices?.[0] !== undefined) {
+ const fkIndex = fkIndices[0];
+ const fk = relationshipMap.fkRestrictions[fkIndex];
+ const mappingIdx = fk.mappings.findIndex((m) => m.localField === field.name);
+ activateRelationship(fkIndex, mappingIdx !== -1 ? mappingIdx : undefined);
+ }
+ }
+ : undefined;
return (
-
+
{field.name}
diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts
index 2a79af6a..41ef6d7b 100644
--- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts
+++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/diagramUtils.ts
@@ -21,7 +21,7 @@
import type { Dictionary, Schema } from '@overture-stack/lectern-dictionary';
import { type Edge, type Node, MarkerType } from 'reactflow';
-import { ONE_CARDINALITY_MARKER_ID } from '../../theme/icons/OneCardinalityMarker';
+import { ONE_CARDINALITY_MARKER_ID, ONE_CARDINALITY_MARKER_ACTIVE_ID } from '../../theme/icons/OneCardinalityMarker';
export type SchemaFlowNode = Node
;
@@ -39,6 +39,28 @@ function buildSchemaNode(schema: Schema): Omit {
};
}
+export const createFieldHandleId = (schemaName: string, fieldName: string, type: 'source' | 'target'): string =>
+ `${schemaName}-${fieldName}-${type}`;
+
+type FkRestrictionInfo = {
+ localSchema: string;
+ foreignSchema: string;
+ mappings: { localField: string; foreignField: string }[];
+ edgeIds: string[];
+ fieldKeys: string[];
+ localFieldKeys: string[];
+ foreignFieldKeys: string[];
+};
+
+export type RelationshipEdgeData = { fkIndex: number; mappingIndex: number };
+
+export type RelationshipMap = {
+ fkRestrictions: FkRestrictionInfo[];
+ localFieldKeyToFkIndices: Map;
+ foreignFieldKeyToFkIndices: Map;
+ fieldKeyToFkIndices: Map;
+};
+
/**
* Converts a dictionary's schemas into positioned ReactFlow nodes arranged in a grid layout.
*
@@ -66,39 +88,246 @@ export function getNodesForDictionary(dictionary: Dictionary, layout?: Partial
- `${schemaName}-${fieldName}-${type}`;
-
/**
- * Converts a dictionary's foreign key relationships into ReactFlow edges connecting schema nodes.
+ * Builds an FK adjacency graph from the dictionary's foreign key restrictions.
+ * Each FK restriction is indexed, and adjacency maps allow tracing chains
+ * up (child→parent) and down (parent→child).
*
* @param {Dictionary} dictionary — The Lectern dictionary containing schemas with foreign key restrictions
- * @returns {Edge[]} Array of ReactFlow edges representing foreign key relationships
+ * @returns {RelationshipMap} FK adjacency graph for chain tracing
*/
-export function getEdgesForDictionary(dictionary: Dictionary): Edge[] {
- return dictionary.schemas.flatMap((schema) => {
- if (!schema.restrictions?.foreignKey) return [];
-
- return schema.restrictions.foreignKey.flatMap((foreignKey) => {
- return foreignKey.mappings.map((mapping) => ({
- id: `${schema.name}-${mapping.local}-to-${foreignKey.schema}-${mapping.foreign}`,
- source: foreignKey.schema,
- sourceHandle: createFieldHandleId(foreignKey.schema, mapping.foreign, 'source'),
- target: schema.name,
- targetHandle: createFieldHandleId(schema.name, mapping.local, 'target'),
- type: 'smoothstep',
- style: { stroke: '#374151', strokeWidth: 2 },
- pathOptions: {
- offset: -20,
- },
- markerEnd: {
- type: MarkerType.Arrow,
- width: 20,
- height: 20,
- color: '#374151',
- },
- markerStart: ONE_CARDINALITY_MARKER_ID,
- }));
+export function buildRelationshipMap(dictionary: Dictionary): RelationshipMap {
+ const fkRestrictions: FkRestrictionInfo[] = [];
+ const localFieldKeyToFkIndices = new Map();
+ const foreignFieldKeyToFkIndices = new Map();
+ const fieldKeyToFkIndices = new Map();
+
+ const addToList = (map: Map, key: string, index: number) => {
+ const existing = map.get(key) ?? [];
+ existing.push(index);
+ map.set(key, existing);
+ };
+
+ dictionary.schemas.forEach((schema) => {
+ if (!schema.restrictions?.foreignKey) return;
+
+ schema.restrictions.foreignKey.forEach((foreignKey) => {
+ const fkIndex = fkRestrictions.length;
+ const mappings: { localField: string; foreignField: string }[] = [];
+ const edgeIds: string[] = [];
+ const fieldKeys: string[] = [];
+ const localFieldKeys: string[] = [];
+ const foreignFieldKeys: string[] = [];
+
+ foreignKey.mappings.forEach((mapping) => {
+ const edgeId = `${schema.name}-${mapping.local}-to-${foreignKey.schema}-${mapping.foreign}`;
+ mappings.push({ localField: mapping.local, foreignField: mapping.foreign });
+ edgeIds.push(edgeId);
+
+ const localKey = `${schema.name}::${mapping.local}`;
+ const foreignKey_ = `${foreignKey.schema}::${mapping.foreign}`;
+ fieldKeys.push(localKey, foreignKey_);
+ localFieldKeys.push(localKey);
+ foreignFieldKeys.push(foreignKey_);
+ addToList(fieldKeyToFkIndices, localKey, fkIndex);
+ addToList(fieldKeyToFkIndices, foreignKey_, fkIndex);
+ addToList(localFieldKeyToFkIndices, localKey, fkIndex);
+ addToList(foreignFieldKeyToFkIndices, foreignKey_, fkIndex);
+ });
+
+ fkRestrictions.push({
+ localSchema: schema.name,
+ foreignSchema: foreignKey.schema,
+ mappings,
+ edgeIds,
+ fieldKeys,
+ localFieldKeys,
+ foreignFieldKeys,
+ });
});
});
+
+ return { fkRestrictions, localFieldKeyToFkIndices, foreignFieldKeyToFkIndices, fieldKeyToFkIndices };
+}
+
+/**
+ * Traces the full FK chain from a clicked edge or field, following parent links upward
+ * and child links downward to collect all connected edges and field keys.
+ *
+ * When `mappingIndex` is provided (edge click), only the specific mapping's edge and
+ * field pair are collected, and chains are traced through individual fields.
+ * When `mappingIndex` is omitted (field click), all mappings of the FK are collected.
+ *
+ * @param {number} fkIndex — The index into fkRestrictions for the clicked FK
+ * @param {RelationshipMap} map — The FK adjacency graph
+ * @param {number} [mappingIndex] — Optional index of the specific mapping within the FK
+ * @returns {{ edgeIds: Set, fieldKeys: Set, schemaChain: string[] }} All edges and fields in the chain
+ */
+export function traceChain(
+ fkIndex: number,
+ map: RelationshipMap,
+ mappingIndex?: number,
+): { edgeIds: Set; fieldKeys: Set; schemaChain: string[] } {
+ const edgeIds = new Set();
+ const fieldKeys = new Set();
+ const visitedEdges = new Set();
+
+ if (fkIndex < 0 || fkIndex >= map.fkRestrictions.length) return { edgeIds, fieldKeys, schemaChain: [] };
+
+ const collectMapping = (fkIdx: number, mapIdx: number) => {
+ const fk = map.fkRestrictions[fkIdx];
+ const edgeId = fk.edgeIds[mapIdx];
+ if (!edgeId || visitedEdges.has(edgeId)) return;
+ visitedEdges.add(edgeId);
+ edgeIds.add(edgeId);
+
+ const mapping = fk.mappings[mapIdx];
+ const localKey = `${fk.localSchema}::${mapping.localField}`;
+ const foreignKey = `${fk.foreignSchema}::${mapping.foreignField}`;
+ fieldKeys.add(localKey);
+ fieldKeys.add(foreignKey);
+ };
+
+ const collectAllMappings = (fkIdx: number) => {
+ const fk = map.fkRestrictions[fkIdx];
+ for (let i = 0; i < fk.mappings.length; i++) {
+ collectMapping(fkIdx, i);
+ }
+ };
+
+ // Trace a single field key in a direction, finding which specific mapping in a connected FK uses that field
+ const traceFieldUp = (foreignFieldKey: string) => {
+ const indices = map.localFieldKeyToFkIndices.get(foreignFieldKey);
+ if (!indices) return;
+ for (const idx of indices) {
+ const fk = map.fkRestrictions[idx];
+ for (let i = 0; i < fk.mappings.length; i++) {
+ const localKey = `${fk.localSchema}::${fk.mappings[i].localField}`;
+ if (localKey === foreignFieldKey) {
+ const edgeId = fk.edgeIds[i];
+ if (!visitedEdges.has(edgeId)) {
+ collectMapping(idx, i);
+ const fKey = `${fk.foreignSchema}::${fk.mappings[i].foreignField}`;
+ traceFieldUp(fKey);
+ }
+ }
+ }
+ }
+ };
+
+ const traceFieldDown = (localFieldKey: string) => {
+ const indices = map.foreignFieldKeyToFkIndices.get(localFieldKey);
+ if (!indices) return;
+ for (const idx of indices) {
+ const fk = map.fkRestrictions[idx];
+ for (let i = 0; i < fk.mappings.length; i++) {
+ const foreignKey = `${fk.foreignSchema}::${fk.mappings[i].foreignField}`;
+ if (foreignKey === localFieldKey) {
+ const edgeId = fk.edgeIds[i];
+ if (!visitedEdges.has(edgeId)) {
+ collectMapping(idx, i);
+ const lKey = `${fk.localSchema}::${fk.mappings[i].localField}`;
+ traceFieldDown(lKey);
+ }
+ }
+ }
+ }
+ };
+
+ const clickedFk = map.fkRestrictions[fkIndex];
+
+ if (mappingIndex !== undefined) {
+ // Edge click: collect only the specific mapping, trace through individual fields
+ collectMapping(fkIndex, mappingIndex);
+ const mapping = clickedFk.mappings[mappingIndex];
+ const foreignKey = `${clickedFk.foreignSchema}::${mapping.foreignField}`;
+ const localKey = `${clickedFk.localSchema}::${mapping.localField}`;
+ traceFieldUp(foreignKey);
+ traceFieldDown(localKey);
+ } else {
+ // Field click: collect all mappings of the FK, trace through all fields
+ collectAllMappings(fkIndex);
+ for (const foreignFieldKey of clickedFk.foreignFieldKeys) {
+ traceFieldUp(foreignFieldKey);
+ }
+ for (const localFieldKey of clickedFk.localFieldKeys) {
+ traceFieldDown(localFieldKey);
+ }
+ }
+
+ const visitedFkIndices = new Set();
+ for (const edgeId of visitedEdges) {
+ for (let i = 0; i < map.fkRestrictions.length; i++) {
+ if (map.fkRestrictions[i].edgeIds.includes(edgeId)) {
+ visitedFkIndices.add(i);
+ }
+ }
+ }
+ const schemaChain = buildSchemaChain(visitedFkIndices, map);
+
+ return { edgeIds, fieldKeys, schemaChain };
+}
+
+function buildSchemaChain(visitedFkIndices: Set, map: RelationshipMap): string[] {
+ const schemas = new Set();
+ for (const idx of visitedFkIndices) {
+ const fk = map.fkRestrictions[idx];
+ schemas.add(fk.foreignSchema);
+ schemas.add(fk.localSchema);
+ }
+ return Array.from(schemas);
+}
+
+/**
+ * Returns a new edges array with className set based on the active edge set.
+ * Active edges get 'edge-active', non-active edges get 'edge-inactive',
+ * and when no relationship is active all edges have no className.
+ */
+export function getEdgesWithHighlight(edges: Edge[], activeEdgeIds: Set | null, activeColor?: string): Edge[] {
+ if (!activeEdgeIds) {
+ return edges.map((edge) => ({
+ ...edge,
+ className: undefined,
+ markerStart: ONE_CARDINALITY_MARKER_ID,
+ markerEnd: { type: MarkerType.Arrow, width: 20, height: 20, color: '#374151' },
+ }));
+ }
+
+ return edges.map((edge) => {
+ const isActive = activeEdgeIds.has(edge.id);
+ return {
+ ...edge,
+ className: isActive ? 'edge-active' : 'edge-inactive',
+ markerStart: isActive ? ONE_CARDINALITY_MARKER_ACTIVE_ID : ONE_CARDINALITY_MARKER_ID,
+ markerEnd: {
+ type: MarkerType.Arrow,
+ width: 20,
+ height: 20,
+ color: isActive ? (activeColor ?? '#374151') : '#374151',
+ },
+ };
+ });
+}
+
+/**
+ * Derives ReactFlow edges from the relationship map, attaching fkIndex to each edge's data
+ *
+ * @param {RelationshipMap} map — The FK adjacency graph built by buildRelationshipMap
+ * @returns {Edge[]} Array of ReactFlow edges representing foreign key relationships
+ */
+export function getEdgesFromMap(map: RelationshipMap): Edge[] {
+ return map.fkRestrictions.flatMap((fk, fkIndex) =>
+ fk.mappings.map((mapping, i) => ({
+ id: fk.edgeIds[i],
+ source: fk.foreignSchema,
+ sourceHandle: createFieldHandleId(fk.foreignSchema, mapping.foreignField, 'source'),
+ target: fk.localSchema,
+ targetHandle: createFieldHandleId(fk.localSchema, mapping.localField, 'target'),
+ type: 'smoothstep',
+ pathOptions: { offset: -20 },
+ data: { fkIndex, mappingIndex: i } satisfies RelationshipEdgeData,
+ markerEnd: { type: MarkerType.Arrow, width: 20, height: 20, color: '#374151' },
+ markerStart: ONE_CARDINALITY_MARKER_ID,
+ })),
+ );
}
diff --git a/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts b/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts
index f13bdc44..195dd06a 100644
--- a/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts
+++ b/packages/ui/src/viewer-table/EntityRelationshipDiagram/index.ts
@@ -19,4 +19,6 @@
*
*/
-export { EntityRelationshipDiagram } from './EntityRelationshipDiagram';
+export { EntityRelationshipDiagramContent } from './EntityRelationshipDiagram';
+export { ActiveRelationshipProvider, useActiveRelationship } from './ActiveRelationshipContext';
+export { buildRelationshipMap } from './diagramUtils';
diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx
new file mode 100644
index 00000000..02f347b7
--- /dev/null
+++ b/packages/ui/src/viewer-table/Toolbar/DiagramSubtitle.tsx
@@ -0,0 +1,97 @@
+/*
+ *
+ * Copyright (c) 2026 The Ontario Institute for Cancer Research. All rights reserved
+ *
+ * This program and the accompanying materials are made available under the terms of
+ * the GNU Affero General Public License v3.0. You should have received a copy of the
+ * GNU Affero General Public License along with this program.
+ * If not, see .
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+ * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+ * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ */
+
+/** @jsxImportSource @emotion/react */
+
+import { css } from '@emotion/react';
+import { useActiveRelationship } from '../EntityRelationshipDiagram';
+import { useThemeContext, type Theme } from '../../theme/index';
+
+const pillStyle = (theme: Theme) => css`
+ display: inline-block;
+ border: 1px solid black;
+ border-radius: 12px;
+ padding: 2px 10px;
+ ${theme.typography.data};
+ color: black;
+`;
+
+const arrowStyle = css`
+ margin: 0 6px;
+`;
+
+const clearButtonStyle = (theme: Theme) => css`
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: black;
+ ${theme.typography.data};
+ text-decoration: underline;
+ padding: 0;
+ margin-top: 4px;
+`;
+
+const chainRowStyle = css`
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 2px;
+`;
+
+const chainItemStyle = css`
+ display: inline-flex;
+ align-items: center;
+`;
+
+const chainLabelStyle = css`
+ font-weight: bold;
+ padding-right: 5px;
+`;
+
+const DiagramSubtitle = () => {
+ const { activeSchemaChain, deactivateRelationship } = useActiveRelationship();
+ const theme = useThemeContext();
+
+ if (!activeSchemaChain) {
+ return Select any key field or edge to highlight a relation.;
+ }
+
+ return (
+
+
+ Highlighting schema relation:
+ {activeSchemaChain.map((schema, index) => (
+
+ {index > 0 && {'\u2192'}}
+ {schema}
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default DiagramSubtitle;
diff --git a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx
index dc5c1bc7..982b97e2 100644
--- a/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx
+++ b/packages/ui/src/viewer-table/Toolbar/DiagramViewButton.tsx
@@ -19,12 +19,17 @@
*
*/
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
import Button from '../../common/Button';
import Modal from '../../common/Modal';
import { useDictionaryDataContext, useDictionaryStateContext } from '../../dictionary-controller/DictionaryDataContext';
import { useThemeContext } from '../../theme/index';
-import { EntityRelationshipDiagram } from '../EntityRelationshipDiagram';
+import {
+ ActiveRelationshipProvider,
+ buildRelationshipMap,
+ EntityRelationshipDiagramContent,
+} from '../EntityRelationshipDiagram';
+import DiagramSubtitle from './DiagramSubtitle';
const DiagramViewButton = () => {
const [isOpen, setIsOpen] = useState(false);
@@ -33,23 +38,32 @@ const DiagramViewButton = () => {
const { loading, errors } = useDictionaryDataContext();
const { selectedDictionary } = useDictionaryStateContext();
+ const relationshipMap = useMemo(
+ () => (selectedDictionary ? buildRelationshipMap(selectedDictionary) : null),
+ [selectedDictionary],
+ );
+
return (
<>
} onClick={() => setIsOpen(true)} disabled={loading || errors.length > 0} isLoading={loading}>
Diagram View
-
- {selectedDictionary && (
-
-
-
- )}
-
+ {relationshipMap && (
+
+ }
+ isOpen={isOpen}
+ setIsOpen={setIsOpen}
+ >
+ {selectedDictionary && (
+
+
+
+ )}
+
+
+ )}
>
);
};
diff --git a/packages/ui/stories/fixtures/compoundKey.json b/packages/ui/stories/fixtures/compoundKey.json
new file mode 100644
index 00000000..da2e3abb
--- /dev/null
+++ b/packages/ui/stories/fixtures/compoundKey.json
@@ -0,0 +1,37 @@
+{
+ "name": "compound_key_dictionary",
+ "version": "1.0",
+ "description": "Tests a schema with a compound uniqueKey across two fields",
+ "schemas": [
+ {
+ "name": "participant",
+ "description": "Study participants",
+ "fields": [
+ { "name": "participant_id", "valueType": "string", "unique": true },
+ { "name": "age", "valueType": "integer" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["participant_id"]
+ }
+ },
+ {
+ "name": "visit",
+ "description": "Participant visits. Uniqueness requires both participant and visit number",
+ "fields": [
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "visit_number", "valueType": "integer" },
+ { "name": "visit_date", "valueType": "string" },
+ { "name": "visit_type", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["participant_id", "visit_number"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/cyclical.json b/packages/ui/stories/fixtures/cyclical.json
new file mode 100644
index 00000000..cb446033
--- /dev/null
+++ b/packages/ui/stories/fixtures/cyclical.json
@@ -0,0 +1,61 @@
+{
+ "name": "cyclical_dictionary",
+ "version": "1.0",
+ "description": "Three schemas forming a cyclical FK reference: node_a -> node_b -> node_c -> node_a",
+ "schemas": [
+ {
+ "name": "node_a",
+ "description": "References node_c, completing the cycle",
+ "fields": [
+ { "name": "a_id", "valueType": "string", "unique": true },
+ { "name": "c_id", "valueType": "string" },
+ { "name": "label", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["a_id"],
+ "foreignKey": [
+ {
+ "schema": "node_c",
+ "mappings": [{ "local": "c_id", "foreign": "c_id" }]
+ }
+ ]
+ }
+ },
+ {
+ "name": "node_b",
+ "description": "References node_a",
+ "fields": [
+ { "name": "b_id", "valueType": "string", "unique": true },
+ { "name": "a_id", "valueType": "string" },
+ { "name": "label", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["b_id"],
+ "foreignKey": [
+ {
+ "schema": "node_a",
+ "mappings": [{ "local": "a_id", "foreign": "a_id" }]
+ }
+ ]
+ }
+ },
+ {
+ "name": "node_c",
+ "description": "References node_b",
+ "fields": [
+ { "name": "c_id", "valueType": "string", "unique": true },
+ { "name": "b_id", "valueType": "string" },
+ { "name": "label", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["c_id"],
+ "foreignKey": [
+ {
+ "schema": "node_b",
+ "mappings": [{ "local": "b_id", "foreign": "b_id" }]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/fanOut.json b/packages/ui/stories/fixtures/fanOut.json
new file mode 100644
index 00000000..a88ca348
--- /dev/null
+++ b/packages/ui/stories/fixtures/fanOut.json
@@ -0,0 +1,72 @@
+{
+ "name": "fan_out_dictionary",
+ "version": "1.0",
+ "description": "One parent schema referenced by multiple independent child schemas",
+ "schemas": [
+ {
+ "name": "participant",
+ "description": "Root participant record referenced by all other schemas",
+ "fields": [
+ { "name": "participant_id", "valueType": "string", "unique": true },
+ { "name": "age", "valueType": "integer" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["participant_id"]
+ }
+ },
+ {
+ "name": "diagnosis",
+ "description": "Diagnoses — FK to participant",
+ "fields": [
+ { "name": "diagnosis_id", "valueType": "string", "unique": true },
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "disease_code", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["diagnosis_id"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ }
+ ]
+ }
+ },
+ {
+ "name": "demographic",
+ "description": "Demographic data — FK to participant",
+ "fields": [
+ { "name": "demographic_id", "valueType": "string", "unique": true },
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "ethnicity", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["demographic_id"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ }
+ ]
+ }
+ },
+ {
+ "name": "follow_up",
+ "description": "Follow-up contacts — FK to participant",
+ "fields": [
+ { "name": "follow_up_id", "valueType": "string", "unique": true },
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "follow_up_date", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["follow_up_id"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/invalid_uniquekey.json b/packages/ui/stories/fixtures/invalid_uniquekey.json
new file mode 100644
index 00000000..3a94fa51
--- /dev/null
+++ b/packages/ui/stories/fixtures/invalid_uniquekey.json
@@ -0,0 +1,32 @@
+{
+ "name": "invalid_uniquekey_reference",
+ "version": "1.0.0",
+ "description": "FK references a field that exists on the target schema but is not declared as a uniqueKey",
+ "schemas": [
+ {
+ "name": "participant",
+ "description": "Participant schema where participant_id is NOT declared as a uniqueKey",
+ "fields": [
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "age", "valueType": "integer" }
+ ]
+ },
+ {
+ "name": "sample",
+ "description": "Sample with FK pointing to participant_id which has no uniqueKey declaration",
+ "fields": [
+ { "name": "sample_id", "valueType": "string", "unique": true },
+ { "name": "participant_id", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["sample_id"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/mixedRelations.json b/packages/ui/stories/fixtures/mixedRelations.json
new file mode 100644
index 00000000..b2e8094b
--- /dev/null
+++ b/packages/ui/stories/fixtures/mixedRelations.json
@@ -0,0 +1,83 @@
+{
+ "name": "mixed_relations_dictionary",
+ "version": "1.0",
+ "description": "Five schemas with a mix of chain, fan-in, and isolated relationships",
+ "schemas": [
+ {
+ "name": "participant",
+ "description": "Study participants",
+ "fields": [
+ { "name": "participant_id", "valueType": "string", "unique": true },
+ { "name": "age", "valueType": "integer" },
+ { "name": "sex", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["participant_id"]
+ }
+ },
+ {
+ "name": "specimen",
+ "description": "Biological specimens from participants",
+ "fields": [
+ { "name": "specimen_id", "valueType": "string", "unique": true },
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "tissue_type", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["specimen_id"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ }
+ ]
+ }
+ },
+ {
+ "name": "experiment",
+ "description": "Sequencing experiments",
+ "fields": [
+ { "name": "experiment_id", "valueType": "string", "unique": true },
+ { "name": "experiment_type", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["experiment_id"]
+ }
+ },
+ {
+ "name": "sample",
+ "description": "Samples linking a specimen to an experiment",
+ "fields": [
+ { "name": "sample_id", "valueType": "string", "unique": true },
+ { "name": "specimen_id", "valueType": "string" },
+ { "name": "experiment_id", "valueType": "string" },
+ { "name": "sample_type", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["sample_id"],
+ "foreignKey": [
+ {
+ "schema": "specimen",
+ "mappings": [{ "local": "specimen_id", "foreign": "specimen_id" }]
+ },
+ {
+ "schema": "experiment",
+ "mappings": [{ "local": "experiment_id", "foreign": "experiment_id" }]
+ }
+ ]
+ }
+ },
+ {
+ "name": "audit_log",
+ "description": "Standalone audit records — no FK relationships",
+ "fields": [
+ { "name": "log_id", "valueType": "string", "unique": true },
+ { "name": "action", "valueType": "string" },
+ { "name": "timestamp", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["log_id"]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/multiFk.json b/packages/ui/stories/fixtures/multiFk.json
new file mode 100644
index 00000000..3ca6c2d6
--- /dev/null
+++ b/packages/ui/stories/fixtures/multiFk.json
@@ -0,0 +1,52 @@
+{
+ "name": "multi_fk",
+ "version": "1.0",
+ "description": "One schema with FKs pointing to two separate parent schemas",
+ "schemas": [
+ {
+ "name": "participant",
+ "description": "Study participants",
+ "fields": [
+ { "name": "participant_id", "valueType": "string", "unique": true },
+ { "name": "age", "valueType": "integer" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["participant_id"]
+ }
+ },
+ {
+ "name": "study",
+ "description": "Research studies",
+ "fields": [
+ { "name": "study_id", "valueType": "string", "unique": true },
+ { "name": "study_name", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["study_id"]
+ }
+ },
+ {
+ "name": "enrollment",
+ "description": "Participant enrollment in a study — links both parent schemas",
+ "fields": [
+ { "name": "enrollment_id", "valueType": "string", "unique": true },
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "study_id", "valueType": "string" },
+ { "name": "enrollment_date", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["enrollment_id"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ },
+ {
+ "schema": "study",
+ "mappings": [{ "local": "study_id", "foreign": "study_id" }]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/simpleClinicalERDiagram.json b/packages/ui/stories/fixtures/simpleClinicalERDiagram.json
new file mode 100644
index 00000000..0a097a0e
--- /dev/null
+++ b/packages/ui/stories/fixtures/simpleClinicalERDiagram.json
@@ -0,0 +1,193 @@
+{
+ "name": "Simple ER Diagram Example",
+ "version": "1.0",
+ "description": "A simplified clinical data model demonstrating entity relationships",
+ "schemas": [
+ {
+ "name": "Participant",
+ "description": "Study participants",
+ "fields": [
+ {
+ "name": "participant_id",
+ "description": "Unique identifier for the participant",
+ "valueType": "string",
+ "unique": true,
+ "restrictions": { "required": true }
+ },
+ {
+ "name": "duo_modifier",
+ "description": "Data Use Ontology modifier",
+ "valueType": "string"
+ },
+ {
+ "name": "treatment_id",
+ "description": "Reference to treatment",
+ "valueType": "string",
+ "unique": true
+ }
+ ],
+ "restrictions": {
+ "uniqueKey": ["participant_id"]
+ }
+ },
+ {
+ "name": "Diagnosis",
+ "description": "Medical diagnoses for participants",
+ "fields": [
+ {
+ "name": "diagnosis_id",
+ "description": "Unique identifier for the diagnosis",
+ "valueType": "string",
+ "unique": true,
+ "restrictions": { "required": true }
+ },
+ {
+ "name": "participant_id",
+ "description": "Reference to participant",
+ "valueType": "string",
+ "unique": true,
+ "restrictions": { "required": true }
+ },
+ {
+ "name": "primary_site",
+ "description": "Primary site of disease",
+ "valueType": "string"
+ },
+ {
+ "name": "age_at_diagnosis",
+ "description": "Age at time of diagnosis",
+ "valueType": "integer"
+ },
+ {
+ "name": "cancer_type",
+ "description": "Type of cancer diagnosed",
+ "valueType": "string"
+ },
+ {
+ "name": "staging system",
+ "description": "Cancer staging system used",
+ "valueType": "string"
+ }
+ ],
+ "restrictions": {
+ "uniqueKey": ["diagnosis_id"],
+ "foreignKey": [
+ {
+ "schema": "Participant",
+ "mappings": [
+ {
+ "local": "participant_id",
+ "foreign": "participant_id"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "name": "Sequencing",
+ "description": "Sequencing data related to treatments",
+ "fields": [
+ {
+ "name": "participant_id",
+ "description": "Reference to participant",
+ "valueType": "string",
+ "unique": true,
+ "restrictions": { "required": true }
+ },
+ {
+ "name": "treatment_id",
+ "description": "Unique treatment identifier",
+ "valueType": "string",
+ "unique": true,
+ "restrictions": { "required": true }
+ },
+ {
+ "name": "treatment_type",
+ "description": "Type of treatment administered",
+ "valueType": "string"
+ },
+ {
+ "name": "treatment_start",
+ "description": "Start day of treatment",
+ "valueType": "integer"
+ },
+ {
+ "name": "treatment_duration",
+ "description": "Duration of treatment in days",
+ "valueType": "integer"
+ },
+ {
+ "name": "treatment_response",
+ "description": "Response to treatment",
+ "valueType": "string"
+ }
+ ],
+ "restrictions": {
+ "uniqueKey": ["treatment_id"],
+ "foreignKey": [
+ {
+ "schema": "Participant",
+ "mappings": [
+ {
+ "local": "participant_id",
+ "foreign": "participant_id"
+ }
+ ]
+ },
+ {
+ "schema": "Participant",
+ "mappings": [
+ {
+ "local": "treatment_id" ,
+ "foreign": "treatment_id"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "name": "Followup",
+ "description": "Follow-up records for treatments",
+ "fields": [
+ {
+ "name": "treatment_id",
+ "description": "Reference to treatment",
+ "valueType": "string",
+ "unique": true,
+ "restrictions": { "required": true }
+ },
+ {
+ "name": "followup_id",
+ "description": "Unique follow-up identifier",
+ "valueType": "string"
+ },
+ {
+ "name": "followup_interval",
+ "description": "Days since treatment started",
+ "valueType": "integer"
+ },
+ {
+ "name": "disease status",
+ "description": "Current disease status",
+ "valueType": "string"
+ }
+ ],
+ "restrictions": {
+ "uniqueKey": ["treatment_id"],
+ "foreignKey": [
+ {
+ "schema": "Sequencing",
+ "mappings": [
+ {
+ "local": "treatment_id",
+ "foreign": "treatment_id"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/singleSchema.json b/packages/ui/stories/fixtures/singleSchema.json
new file mode 100644
index 00000000..c1c9bf4b
--- /dev/null
+++ b/packages/ui/stories/fixtures/singleSchema.json
@@ -0,0 +1,20 @@
+{
+ "name": "single_schema_dictionary",
+ "version": "1.0",
+ "description": "A dictionary with one schema and no relationships",
+ "schemas": [
+ {
+ "name": "sample",
+ "description": "Basic sample metadata",
+ "fields": [
+ { "name": "sample_id", "valueType": "string", "unique": true },
+ { "name": "sample_type", "valueType": "string" },
+ { "name": "collection_date", "valueType": "string" },
+ { "name": "volume_ml", "valueType": "number" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["sample_id"]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/threeSchemaChain.json b/packages/ui/stories/fixtures/threeSchemaChain.json
new file mode 100644
index 00000000..f7429122
--- /dev/null
+++ b/packages/ui/stories/fixtures/threeSchemaChain.json
@@ -0,0 +1,54 @@
+{
+ "name": "three_schema_chain",
+ "version": "1.0",
+ "description": "Three schemas forming a linear FK chain",
+ "schemas": [
+ {
+ "name": "participant",
+ "description": "Study participants",
+ "fields": [
+ { "name": "participant_id", "valueType": "string", "unique": true },
+ { "name": "age", "valueType": "integer" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["participant_id"]
+ }
+ },
+ {
+ "name": "diagnosis",
+ "description": "Diagnoses linked to participants",
+ "fields": [
+ { "name": "diagnosis_id", "valueType": "string", "unique": true },
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "disease_code", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["diagnosis_id"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ }
+ ]
+ }
+ },
+ {
+ "name": "treatment",
+ "description": "Treatments linked to diagnoses",
+ "fields": [
+ { "name": "treatment_id", "valueType": "string", "unique": true },
+ { "name": "diagnosis_id", "valueType": "string" },
+ { "name": "treatment_type", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["treatment_id"],
+ "foreignKey": [
+ {
+ "schema": "diagnosis",
+ "mappings": [{ "local": "diagnosis_id", "foreign": "diagnosis_id" }]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/twoIsolatedSchemas.json b/packages/ui/stories/fixtures/twoIsolatedSchemas.json
new file mode 100644
index 00000000..96630d64
--- /dev/null
+++ b/packages/ui/stories/fixtures/twoIsolatedSchemas.json
@@ -0,0 +1,37 @@
+{
+ "name": "two_schema_linear",
+ "version": "1.0",
+ "description": "Two schemas with a simple one-to-many FK relationship",
+ "schemas": [
+ {
+ "name": "participant",
+ "description": "Study participants",
+ "fields": [
+ { "name": "participant_id", "valueType": "string", "unique": true },
+ { "name": "age", "valueType": "integer" },
+ { "name": "sex", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["participant_id"]
+ }
+ },
+ {
+ "name": "sample",
+ "description": "Samples collected from participants",
+ "fields": [
+ { "name": "sample_id", "valueType": "string", "unique": true },
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "sample_type", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["sample_id"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/fixtures/twoSchemaLinear.json b/packages/ui/stories/fixtures/twoSchemaLinear.json
new file mode 100644
index 00000000..96630d64
--- /dev/null
+++ b/packages/ui/stories/fixtures/twoSchemaLinear.json
@@ -0,0 +1,37 @@
+{
+ "name": "two_schema_linear",
+ "version": "1.0",
+ "description": "Two schemas with a simple one-to-many FK relationship",
+ "schemas": [
+ {
+ "name": "participant",
+ "description": "Study participants",
+ "fields": [
+ { "name": "participant_id", "valueType": "string", "unique": true },
+ { "name": "age", "valueType": "integer" },
+ { "name": "sex", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["participant_id"]
+ }
+ },
+ {
+ "name": "sample",
+ "description": "Samples collected from participants",
+ "fields": [
+ { "name": "sample_id", "valueType": "string", "unique": true },
+ { "name": "participant_id", "valueType": "string" },
+ { "name": "sample_type", "valueType": "string" }
+ ],
+ "restrictions": {
+ "uniqueKey": ["sample_id"],
+ "foreignKey": [
+ {
+ "schema": "participant",
+ "mappings": [{ "local": "participant_id", "foreign": "participant_id" }]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx b/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx
index a1e5676d..e50c04f9 100644
--- a/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx
+++ b/packages/ui/stories/viewer-table/EntityRelationshipDiagram.stories.tsx
@@ -21,32 +21,177 @@
import type { Meta, StoryObj } from '@storybook/react';
import type { Dictionary } from '@overture-stack/lectern-dictionary';
+import { useMemo } from 'react';
-import { EntityRelationshipDiagram } from '../../src/viewer-table/EntityRelationshipDiagram';
+import React from 'react';
+import {
+ ActiveRelationshipProvider,
+ buildRelationshipMap,
+ EntityRelationshipDiagramContent,
+} from '../../src/viewer-table/EntityRelationshipDiagram';
import DictionarySample from '../fixtures/pcgl.json';
-import SimpleERDiagram from '../fixtures/simpleERDiagram.json';
+import SimpleClinicalERDiagram from '../fixtures/simpleClinicalERDiagram.json';
+import SingleSchemaFixture from '../fixtures/singleSchema.json';
+import TwoIsolatedSchemasFixture from '../fixtures/twoIsolatedSchemas.json';
+import TwoSchemaLinearFixture from '../fixtures/twoSchemaLinear.json';
+import ThreeSchemaChainFixture from '../fixtures/threeSchemaChain.json';
+import MultiFkFixture from '../fixtures/multiFk.json';
+import FanOutFixture from '../fixtures/fanOut.json';
+import MixedRelationsFixture from '../fixtures/mixedRelations.json';
+import CompoundKeyFixture from '../fixtures/compoundKey.json';
+import CyclicalFixture from '../fixtures/cyclical.json';
+import InvalidUniqueKeyFixture from '../fixtures/invalid_uniquekey.json';
import themeDecorator from '../themeDecorator';
-import React from 'react';
const meta = {
- component: EntityRelationshipDiagram,
+ component: EntityRelationshipDiagramContent,
title: 'Viewer - Table/Entity Relationship Diagram',
decorators: [themeDecorator()],
parameters: {
layout: 'fullscreen',
},
-} satisfies Meta;
+} satisfies Meta;
export default meta;
type Story = StoryObj;
+const StoryWrapper = ({ dictionary }: { dictionary: Dictionary }) => {
+ const relationshipMap = useMemo(() => buildRelationshipMap(dictionary), [dictionary]);
+ return (
+
+
+
+ );
+};
+
export const Default: Story = {
args: {
dictionary: DictionarySample as Dictionary,
},
render: (args) => (
-
+
+
+ ),
+};
+
+export const SimpleClinicalExample: Story = {
+ args: {
+ dictionary: SimpleClinicalERDiagram as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const SingleSchema: Story = {
+ args: {
+ dictionary: SingleSchemaFixture as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const TwoIsolatedSchemas: Story = {
+ args: {
+ dictionary: TwoIsolatedSchemasFixture as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const TwoSchemaLinear: Story = {
+ args: {
+ dictionary: TwoSchemaLinearFixture as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const ThreeSchemaChain: Story = {
+ args: {
+ dictionary: ThreeSchemaChainFixture as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const MultiFk: Story = {
+ args: {
+ dictionary: MultiFkFixture as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const FanOut: Story = {
+ args: {
+ dictionary: FanOutFixture as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const MixedRelations: Story = {
+ args: {
+ dictionary: MixedRelationsFixture as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const CompoundKey: Story = {
+ args: {
+ dictionary: CompoundKeyFixture as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const Cyclical: Story = {
+ args: {
+ dictionary: CyclicalFixture as Dictionary,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const NonUniqueForeignKey: Story = {
+ args: {
+ dictionary: InvalidUniqueKeyFixture as Dictionary,
+ },
+ render: (args) => (
+
+
),
};
\ No newline at end of file