glam/frontend/src/components/uml/UMLVisualization.tsx
kempersc 776462de90 Migrate multiple slots to enhance semantic clarity and align with best practices
- Migrated catering_type to CateringType with subclasses for better classification.
- Updated certainty_level to has_or_had_level for improved metadata consistency.
- Addressed cessation_observed_in by confirming existing temporal data structure.
- Created NetAsset class and updated financial statements for richer financial modeling.
- Completed migrations for default_access_policy, default_audio_language, and default_language to structured classes.
- Migrated default_position to structured Alignment class for better representation.
- Updated defined_by_standard to broaden range for identifier standards.
- Migrated definition to structured Resolution class for video resolution modeling.
- Completed migrations for degree_name, deliverable, and departement_code to structured classes.
- Migrated deployment_date to structured DeploymentEvent with temporal extent.
- Migrated derived_from_entity and derived_from_observation to new reference structures.
- Completed description and description_text migrations to enhance content modeling.
- Migrated detection_count, detection_level, and detection_threshold to structured slots with classes.
- Migrated device-related slots to structured classes for better identification and classification.
- Added new slots and classes for historic building and web address modeling.
2026-01-25 12:47:38 +01:00

2963 lines
123 KiB
TypeScript

import React, { useEffect, useRef, useState, memo } from 'react';
import * as d3 from 'd3';
import dagre from 'dagre';
import ELK from 'elkjs/lib/elk.bundled.js';
import './UMLVisualization.css';
import { SemanticDetailsPanel } from './SemanticDetailsPanel';
// Debug logging flag - set to false for production to reduce console noise
const DEBUG_UML = false;
const debugLog = (...args: unknown[]) => DEBUG_UML && console.log(...args);
// ============================================================================
// PERFORMANCE OPTIMIZATION: Text Measurement Cache
// ============================================================================
// Uses Canvas API instead of DOM manipulation for ~10x faster text measurement
// Cache persists across renders to avoid recalculating same text widths
const textWidthCache = new Map<string, number>();
let measurementCanvas: HTMLCanvasElement | null = null;
let measurementContext: CanvasRenderingContext2D | null = null;
/**
* Fast text width measurement using Canvas API with caching
* Much faster than creating/removing SVG elements for each measurement
*/
const measureTextWidthCached = (
text: string,
fontSize: string,
fontWeight: string = 'normal',
fontFamily: string = "'Monaco', 'Courier New', monospace"
): number => {
const cacheKey = `${text}|${fontSize}|${fontWeight}|${fontFamily}`;
// Return cached value if available
const cached = textWidthCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
// Initialize canvas on first use
if (!measurementCanvas) {
measurementCanvas = document.createElement('canvas');
measurementContext = measurementCanvas.getContext('2d');
}
if (!measurementContext) {
// Fallback estimate if canvas not available
return text.length * 7;
}
// Set font and measure
measurementContext.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const width = measurementContext.measureText(text).width;
// Cache the result (limit cache size to prevent memory issues)
if (textWidthCache.size > 5000) {
// Clear oldest entries when cache gets too large
const keysToDelete = Array.from(textWidthCache.keys()).slice(0, 1000);
keysToDelete.forEach(k => textWidthCache.delete(k));
}
textWidthCache.set(cacheKey, width);
return width;
};
export type DiagramType = 'mermaid-class' | 'mermaid-er' | 'plantuml' | 'graphviz';
export interface UMLNode {
id: string;
name: string;
type: 'class' | 'enum' | 'entity' | 'slot';
attributes?: { name: string; type: string }[];
methods?: { name: string; returnType?: string }[];
x?: number;
y?: number;
width?: number;
height?: number;
module?: string; // Module/category for clustered layout (e.g., "core", "provenance", "types")
}
export interface UMLLink {
source: string;
target: string;
type: 'inheritance' | 'composition' | 'aggregation' | 'association' | 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many';
label?: string;
cardinality?: string;
bidirectional?: boolean; // Whether this edge can be reversed
isReversed?: boolean; // Track if edge has been reversed by user
reverseLabel?: string; // Label for reverse direction (e.g., "refers_to_custodian" vs "has_collection")
reverseCardinality?: string; // Cardinality for reverse direction
isProvenance?: boolean; // Whether this is a PROV-O provenance link (was_derived_from, was_generated_by, was_attributed_to)
isSelfLoop?: boolean; // Whether this is a self-referential edge (source === target)
}
// Cardinality notation style
export type CardinalityStyle = 'uml' | 'crowsfoot';
// Parsed cardinality info for each end of a relationship
export interface ParsedCardinality {
min: 0 | 1; // 0 = optional, 1 = required
max: 1 | 'many'; // 1 = exactly one, 'many' = multiple
}
/**
* Parse Mermaid ER cardinality notation into structured format
* Mermaid notation: || (exactly one), |o/o| (zero or one), }|/|{ (one or more), }o/o{ (zero or more)
*/
function parseCardinality(notation: string): { source: ParsedCardinality; target: ParsedCardinality } {
const [leftSide, rightSide] = notation.split('--');
// Parse left side (source cardinality)
const sourceMin: 0 | 1 = leftSide.includes('o') ? 0 : 1;
const sourceMax: 1 | 'many' = leftSide.includes('}') ? 'many' : 1;
// Parse right side (target cardinality)
const targetMin: 0 | 1 = rightSide.includes('o') ? 0 : 1;
const targetMax: 1 | 'many' = (rightSide.includes('{') || rightSide.includes('}')) ? 'many' : 1;
return {
source: { min: sourceMin, max: sourceMax },
target: { min: targetMin, max: targetMax }
};
}
/**
* Get human-readable cardinality label
* Used by legend and tooltips to display cardinality in standard notation
*/
export function getCardinalityLabel(card: ParsedCardinality): string {
if (card.min === 1 && card.max === 1) return '1';
if (card.min === 0 && card.max === 1) return '0..1';
if (card.min === 1 && card.max === 'many') return '1..*';
return '0..*';
}
/**
* Get visual symbol for cardinality based on style (UML or Crow's Foot)
* Returns a string with the symbol and numeric notation
*/
function getCardinalitySymbol(card: ParsedCardinality, style: CardinalityStyle): string {
const label = getCardinalityLabel(card);
if (style === 'crowsfoot') {
if (card.min === 1 && card.max === 1) return `┤ (${label})`;
if (card.min === 0 && card.max === 1) return `○┤ (${label})`;
if (card.min === 1 && card.max === 'many') return `<┤ (${label})`;
return `○< (${label})`;
} else {
// UML style
if (card.min === 1 && card.max === 1) return `● (${label})`;
if (card.min === 0 && card.max === 1) return `○ (${label})`;
if (card.min === 1 && card.max === 'many') return `◆ (${label})`;
return `◇ (${label})`;
}
}
/**
* Format cardinality for tooltip display
* Shows source and target cardinality with visual symbols
*/
function formatCardinalityForTooltip(notation: string, style: CardinalityStyle): string {
const parsed = parseCardinality(notation);
const sourceSymbol = getCardinalitySymbol(parsed.source, style);
const targetSymbol = getCardinalitySymbol(parsed.target, style);
return `${sourceSymbol}${targetSymbol}`;
}
/**
* Check if a cardinality notation includes a specific cardinality type
* Used for legend highlighting when hovering over edges
*/
function cardinalityMatchesType(notation: string | null, type: '1' | '0..1' | '1..*' | '0..*'): boolean {
if (!notation) return false;
const parsed = parseCardinality(notation);
const { source, target } = parsed;
// Check if either source or target matches the type
const matchesSide = (card: ParsedCardinality): boolean => {
const label = getCardinalityLabel(card);
return label === type;
};
return matchesSide(source) || matchesSide(target);
}
/**
* Get marker ID for cardinality based on style
*/
function getCardinalityMarkerId(card: ParsedCardinality, style: CardinalityStyle): string {
if (style === 'crowsfoot') {
if (card.min === 1 && card.max === 1) return 'crowsfoot-one';
if (card.min === 0 && card.max === 1) return 'crowsfoot-zero-one';
if (card.min === 1 && card.max === 'many') return 'crowsfoot-one-many';
return 'crowsfoot-zero-many';
} else {
// UML style
if (card.min === 1 && card.max === 1) return 'card-one-required';
if (card.min === 0 && card.max === 1) return 'card-one-optional';
if (card.min === 1 && card.max === 'many') return 'card-many-required';
return 'card-many-optional';
}
}
export interface UMLDiagram {
nodes: UMLNode[];
links: UMLLink[];
title?: string;
}
export type DagreDirection = 'TB' | 'BT' | 'LR' | 'RL';
export type DagreAlignment = 'UL' | 'UR' | 'DL' | 'DR' | undefined;
export type DagreRanker = 'network-simplex' | 'tight-tree' | 'longest-path';
// Extended layout types including new algorithms
export type LayoutType = 'force' | 'dagre' | 'elk' | 'circular' | 'radial' | 'clustered';
// ELK algorithm options
export type ElkAlgorithm = 'layered' | 'mrtree' | 'force' | 'stress';
interface UMLVisualizationProps {
diagram: UMLDiagram;
width?: number;
height?: number;
diagramType?: DiagramType;
layoutType?: LayoutType;
dagreDirection?: DagreDirection;
dagreAlignment?: DagreAlignment;
dagreRanker?: DagreRanker;
elkAlgorithm?: ElkAlgorithm;
cardinalityStyle?: CardinalityStyle;
onCardinalityStyleChange?: (style: CardinalityStyle) => void;
}
/**
* Custom comparison function for React.memo
* Prevents unnecessary re-renders when props haven't meaningfully changed
*/
const arePropsEqual = (prevProps: UMLVisualizationProps, nextProps: UMLVisualizationProps): boolean => {
// Quick reference equality checks for primitives
if (prevProps.width !== nextProps.width) return false;
if (prevProps.height !== nextProps.height) return false;
if (prevProps.layoutType !== nextProps.layoutType) return false;
if (prevProps.dagreDirection !== nextProps.dagreDirection) return false;
if (prevProps.dagreAlignment !== nextProps.dagreAlignment) return false;
if (prevProps.dagreRanker !== nextProps.dagreRanker) return false;
if (prevProps.elkAlgorithm !== nextProps.elkAlgorithm) return false;
if (prevProps.cardinalityStyle !== nextProps.cardinalityStyle) return false;
// For diagram, check if the content actually changed
// First check reference equality (fast path)
if (prevProps.diagram === nextProps.diagram) return true;
// Deep comparison for diagram - check nodes and links arrays
if (prevProps.diagram.nodes.length !== nextProps.diagram.nodes.length) return false;
if (prevProps.diagram.links.length !== nextProps.diagram.links.length) return false;
if (prevProps.diagram.title !== nextProps.diagram.title) return false;
// Check if node IDs are the same (indicates same structure)
const prevNodeIds = prevProps.diagram.nodes.map(n => n.id).sort().join(',');
const nextNodeIds = nextProps.diagram.nodes.map(n => n.id).sort().join(',');
if (prevNodeIds !== nextNodeIds) return false;
// Check if node attributes have changed (important for async classPrimitiveSlots loading)
// Compare total attribute count as a fast check - if count differs, attributes changed
const prevAttrCount = prevProps.diagram.nodes.reduce((sum, n) => sum + (n.attributes?.length || 0), 0);
const nextAttrCount = nextProps.diagram.nodes.reduce((sum, n) => sum + (n.attributes?.length || 0), 0);
if (prevAttrCount !== nextAttrCount) return false;
// Check if link structure is the same
const prevLinkKeys = prevProps.diagram.links.map(l =>
`${typeof l.source === 'string' ? l.source : (l.source as any).id}->${typeof l.target === 'string' ? l.target : (l.target as any).id}`
).sort().join(',');
const nextLinkKeys = nextProps.diagram.links.map(l =>
`${typeof l.source === 'string' ? l.source : (l.source as any).id}->${typeof l.target === 'string' ? l.target : (l.target as any).id}`
).sort().join(',');
if (prevLinkKeys !== nextLinkKeys) return false;
return true;
};
const UMLVisualizationInner: React.FC<UMLVisualizationProps> = ({
diagram,
width = 1200,
height = 800,
// diagramType = 'mermaid-class', // Unused for now
layoutType = 'force',
dagreDirection = 'TB',
dagreAlignment = undefined,
dagreRanker = 'network-simplex',
elkAlgorithm = 'layered',
cardinalityStyle = 'uml',
onCardinalityStyleChange
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [selectedNode, setSelectedNode] = useState<UMLNode | null>(null);
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null); // Track left-click focused node for edge highlighting
const [zoom, setZoom] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<UMLNode[]>([]);
const [showSearchResults, setShowSearchResults] = useState(false);
const [visibleResultsCount, setVisibleResultsCount] = useState(10);
const [showProvenanceLinks, setShowProvenanceLinks] = useState(false); // Provenance links hidden by default
const [showLegend, setShowLegend] = useState(false); // Cardinality legend dropdown
const [showLinkTypeMenu, setShowLinkTypeMenu] = useState(false); // Link type filter menu
const [showInheritanceLinks, setShowInheritanceLinks] = useState(true); // Inheritance links (is_a)
const [showSlotRangeLinks, setShowSlotRangeLinks] = useState(true); // Slot range links (aggregation/composition)
const [showDualClassLinks, setShowDualClassLinks] = useState(true); // Annotation-based dual-class pattern links
const [showAttributesAsEdges, setShowAttributesAsEdges] = useState(false); // false = inline attributes, true = attributes as separate edges to primitive type nodes
const [edgeTooltip, setEdgeTooltip] = useState<{ label: string; x: number; y: number; bidirectional: boolean; containerWidth: number; containerHeight: number } | null>(null);
const [hoveredEdgeInfo, setHoveredEdgeInfo] = useState<{ relationshipType: string; cardinality: string | null } | null>(null); // Track hovered edge for legend highlighting
const [localCardinalityStyle, setLocalCardinalityStyle] = useState<CardinalityStyle>(cardinalityStyle);
const zoomTransformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity);
const zoomBehaviorRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
const previousDiagramRef = useRef<string | null>(null); // Track diagram changes for auto-fit
const autoFitTimeoutRef = useRef<number | null>(null); // Track auto-fit timeout for cleanup
const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map()); // Store computed node positions for navigation
useEffect(() => {
debugLog('[UMLVisualization v2] useEffect triggered', {
hasSvgRef: !!svgRef.current,
hasDiagram: !!diagram,
layoutType
});
if (!svgRef.current || !diagram) return;
// Cancel any pending auto-fit from previous render (React StrictMode double-render fix)
if (autoFitTimeoutRef.current) {
clearTimeout(autoFitTimeoutRef.current);
autoFitTimeoutRef.current = null;
}
// Determine if this is a new diagram (should auto-fit) or same diagram with depth change (preserve zoom)
// Extract center class name from title (format: "ClassName Relationships (Depth: N)")
// This allows depth changes to preserve camera position while switching classes triggers auto-fit
const centerClass = diagram.title?.match(/^(.+?) Relationships/)?.[1] || diagram.title;
const diagramId = centerClass || JSON.stringify(diagram.nodes.map(n => n.id).sort());
const isNewDiagram = previousDiagramRef.current !== diagramId;
debugLog('[UMLVisualization] Diagram check:', {
diagramId,
centerClass,
previousId: previousDiagramRef.current,
isNewDiagram,
willAutoFit: isNewDiagram
});
previousDiagramRef.current = diagramId;
// Store current zoom transform before clearing (only if same diagram)
const currentSvg = d3.select(svgRef.current);
const currentTransform = d3.zoomTransform(currentSvg.node() as Element);
if (!isNewDiagram && currentTransform && (currentTransform.k !== 1 || currentTransform.x !== 0 || currentTransform.y !== 0)) {
zoomTransformRef.current = currentTransform;
} else if (isNewDiagram) {
// Reset zoom ref for new diagrams
zoomTransformRef.current = d3.zoomIdentity;
}
// Clear previous content
d3.select(svgRef.current).selectAll('*').remove();
// Get actual container dimensions for SVG sizing
const container = containerRef.current;
const actualWidth = container?.clientWidth || width;
const actualHeight = container?.clientHeight || height;
debugLog(`[UMLVisualization] Container dimensions: ${actualWidth}x${actualHeight}`);
// Create SVG with zoom behavior - use 100% to fill container
const svg = d3.select(svgRef.current)
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${actualWidth} ${actualHeight}`)
.attr('preserveAspectRatio', 'xMidYMid meet');
const g = svg.append('g');
// Add zoom behavior - allow zooming out to 1% for large diagrams
const zoomBehavior = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.01, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform);
setZoom(event.transform.k);
});
svg.call(zoomBehavior);
zoomBehaviorRef.current = zoomBehavior; // Store for navigation
// Restore previous zoom transform (preserve camera position across layout changes)
if (zoomTransformRef.current) {
svg.call(zoomBehavior.transform as any, zoomTransformRef.current);
}
// Add custom event listeners for toolbar controls
const handleZoomIn = () => {
svg.transition().duration(300).call(zoomBehavior.scaleBy as any, 1.3);
};
const handleZoomOut = () => {
svg.transition().duration(300).call(zoomBehavior.scaleBy as any, 0.7);
};
const handleResetView = () => {
svg.transition().duration(500).call(
zoomBehavior.transform as any,
d3.zoomIdentity
);
};
const handleFitToScreen = (retryCount = 0, instant = false) => {
// Get bounding box of all nodes
const bounds = g.node()?.getBBox();
// Debug: Log bounds on every attempt
debugLog(`[UMLVisualization] getBBox attempt ${retryCount}:`, bounds ?
{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : 'null');
if (!bounds) return;
// Guard against zero or invalid bounds (can happen if DOM not ready)
if (!bounds.width || !bounds.height || bounds.width < 1 || bounds.height < 1) {
// Retry up to 5 times with increasing delay (DOM may not be ready yet)
if (retryCount < 5) {
const delay = 100 * (retryCount + 1);
debugLog(`[UMLVisualization] Bounds not ready, retrying in ${delay}ms (attempt ${retryCount + 1}/5)`);
setTimeout(() => handleFitToScreen(retryCount + 1, instant), delay);
} else {
console.warn('[UMLVisualization] Bounds still invalid after 5 retries:', bounds);
// DEBUG: Check what's actually in the SVG
const gNode = g.node();
if (gNode) {
debugLog('[UMLVisualization] DEBUG - g element children:', gNode.children.length);
debugLog('[UMLVisualization] DEBUG - g element innerHTML length:', gNode.innerHTML?.length);
}
}
return;
}
// Use actual container dimensions instead of props for accurate fit
const container = containerRef.current;
const fullWidth = container?.clientWidth || width;
const fullHeight = container?.clientHeight || height;
debugLog(`[UMLVisualization] Fit-to-screen using container dimensions: ${fullWidth}x${fullHeight}, instant: ${instant}`);
const midX = bounds.x + bounds.width / 2;
const midY = bounds.y + bounds.height / 2;
// Calculate scale to fit with padding (0.85 gives 15% margin)
const scale = 0.85 / Math.max(bounds.width / fullWidth, bounds.height / fullHeight);
// Guard against NaN/Infinity in calculations
if (!isFinite(scale) || !isFinite(midX) || !isFinite(midY)) {
console.warn('[UMLVisualization] Invalid scale or midpoint, skipping fit:', { scale, midX, midY });
return;
}
const translate = [fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY];
const newTransform = d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale);
if (instant) {
// Apply instantly without animation for initial load
svg.call(zoomBehavior.transform as any, newTransform);
setZoom(scale);
} else {
// Animated transition for user-triggered fit
svg.transition().duration(500).call(
zoomBehavior.transform as any,
newTransform
);
}
};
const handleExportPNG = async (event: Event) => {
const customEvent = event as CustomEvent<{ filename: string }>;
const filename = customEvent.detail?.filename || 'diagram';
const svgElement = svgRef.current;
if (!svgElement) return;
try {
// Get SVG bounding box to determine export size
const bbox = g.node()?.getBBox();
if (!bbox) return;
// Create a canvas with the SVG content
const canvas = document.createElement('canvas');
const padding = 40;
canvas.width = bbox.width + padding * 2;
canvas.height = bbox.height + padding * 2;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Fill white background
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Get SVG data
const svgData = new XMLSerializer().serializeToString(svgElement);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
// Load SVG as image
const img = new Image();
img.onload = () => {
// Get current transform
const transform = d3.zoomTransform(svg.node() as any);
// Draw with transform applied
ctx.save();
ctx.translate(padding - bbox.x * transform.k, padding - bbox.y * transform.k);
ctx.scale(transform.k, transform.k);
ctx.drawImage(img, 0, 0);
ctx.restore();
// Convert to PNG and download
canvas.toBlob((blob) => {
if (!blob) return;
const pngUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = pngUrl;
a.download = `${filename}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(pngUrl);
});
URL.revokeObjectURL(url);
};
img.src = url;
} catch (error) {
console.error('Error exporting PNG:', error);
alert('Failed to export PNG. Please try again.');
}
};
const handleExportSVG = (event: Event) => {
const customEvent = event as CustomEvent<{ filename: string }>;
const filename = customEvent.detail?.filename || 'diagram';
const svgElement = svgRef.current;
if (!svgElement) return;
try {
// Clone SVG to avoid modifying original
const svgClone = svgElement.cloneNode(true) as SVGSVGElement;
// Get bounding box and set viewBox
const bbox = g.node()?.getBBox();
if (bbox) {
const padding = 40;
svgClone.setAttribute('viewBox',
`${bbox.x - padding} ${bbox.y - padding} ${bbox.width + padding * 2} ${bbox.height + padding * 2}`
);
svgClone.setAttribute('width', `${bbox.width + padding * 2}`);
svgClone.setAttribute('height', `${bbox.height + padding * 2}`);
}
// Add XML declaration and styling
const svgData = new XMLSerializer().serializeToString(svgClone);
const fullSvg = '<?xml version="1.0" encoding="UTF-8"?>\n' + svgData;
// Create blob and download
const blob = new Blob([fullSvg], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error exporting SVG:', error);
alert('Failed to export SVG. Please try again.');
}
};
window.addEventListener('uml-zoom-in', handleZoomIn);
window.addEventListener('uml-zoom-out', handleZoomOut);
window.addEventListener('uml-reset-view', handleResetView);
window.addEventListener('uml-fit-to-screen', () => handleFitToScreen());
window.addEventListener('uml-export-png', handleExportPNG as EventListener);
window.addEventListener('uml-export-svg', handleExportSVG as EventListener);
// Cleanup event listeners
const cleanup = () => {
window.removeEventListener('uml-zoom-in', handleZoomIn);
window.removeEventListener('uml-zoom-out', handleZoomOut);
window.removeEventListener('uml-reset-view', handleResetView);
window.removeEventListener('uml-fit-to-screen', () => handleFitToScreen());
window.removeEventListener('uml-export-png', handleExportPNG as EventListener);
window.removeEventListener('uml-export-svg', handleExportSVG as EventListener);
};
// Calculate node dimensions
const minNodeWidth = 150;
const maxNodeWidth = 400;
const nodeHeaderHeight = 40;
const attributeHeight = 24;
const methodHeight = 24;
const nodePadding = 10;
const textPadding = 20; // Horizontal padding for text inside boxes
// Helper function to measure text width - uses cached Canvas measurement
// fontFamily defaults to monospace to match the CSS for attributes/methods
const measureTextWidth = (
text: string,
fontSize: string,
fontWeight: string = 'normal',
fontFamily: string = "'Monaco', 'Courier New', monospace"
): number => {
// Use the cached Canvas-based measurement for better performance
return measureTextWidthCached(text, fontSize, fontWeight, fontFamily);
};
// Calculate dynamic width for each node based on its content
const calculateNodeWidth = (node: UMLNode, hideAttributes: boolean = false): number => {
let maxWidth = minNodeWidth;
// Font families matching CSS
const sansSerifFont = 'system-ui, -apple-system, sans-serif';
const monospaceFont = "'Monaco', 'Courier New', monospace";
// Measure node name (header text - bold, 14px, sans-serif)
const nameWidth = measureTextWidth(node.name, '14px', 'bold', sansSerifFont) + textPadding * 2;
maxWidth = Math.max(maxWidth, nameWidth);
// Measure type badge width (10px, italic, sans-serif)
const typeBadgeWidth = measureTextWidth(`«${node.type}»`, '10px', 'normal', sansSerifFont) + textPadding;
maxWidth = Math.max(maxWidth, typeBadgeWidth);
// Measure attribute widths (12px, monospace - wider font!) - skip if hideAttributes is true
if (node.attributes && !hideAttributes) {
node.attributes.forEach(attr => {
const attrText = `${attr.name}: ${attr.type}`;
const attrWidth = measureTextWidth(attrText, '12px', 'normal', monospaceFont) + textPadding * 2;
maxWidth = Math.max(maxWidth, attrWidth);
});
}
// Measure method widths (12px, monospace - wider font!)
if (node.methods) {
node.methods.forEach(method => {
const methodText = method.returnType
? `${method.name}(): ${method.returnType}`
: `${method.name}()`;
const methodWidth = measureTextWidth(methodText, '12px', 'normal', monospaceFont) + textPadding * 2;
maxWidth = Math.max(maxWidth, methodWidth);
});
}
// Clamp to max width
return Math.min(maxWidth, maxNodeWidth);
};
// Helper function to calculate intersection point of a line with a rectangular box border
// Given a line from (x1,y1) to (x2,y2) and a rectangle centered at (cx,cy) with width w and height h,
// returns the point where the line intersects the rectangle border (from center outward)
const getBoxBorderIntersection = (
cx: number, cy: number, // Rectangle center
w: number, h: number, // Rectangle width and height
targetX: number, targetY: number // Target point (other end of line)
): { x: number; y: number } => {
const dx = targetX - cx;
const dy = targetY - cy;
// If target is at center, return center
if (dx === 0 && dy === 0) {
return { x: cx, y: cy };
}
const halfW = w / 2;
const halfH = h / 2;
// Calculate intersection with each edge and find the closest one
// The line exits through the edge that has the smallest positive t value
let t = Infinity;
// Right edge (x = cx + halfW)
if (dx > 0) {
const tRight = halfW / dx;
if (tRight < t && Math.abs(dy * tRight) <= halfH) {
t = tRight;
}
}
// Left edge (x = cx - halfW)
if (dx < 0) {
const tLeft = -halfW / dx;
if (tLeft < t && Math.abs(dy * tLeft) <= halfH) {
t = tLeft;
}
}
// Bottom edge (y = cy + halfH)
if (dy > 0) {
const tBottom = halfH / dy;
if (tBottom < t && Math.abs(dx * tBottom) <= halfW) {
t = tBottom;
}
}
// Top edge (y = cy - halfH)
if (dy < 0) {
const tTop = -halfH / dy;
if (tTop < t && Math.abs(dx * tTop) <= halfW) {
t = tTop;
}
}
// If no valid intersection found (shouldn't happen), return center
if (t === Infinity) {
return { x: cx, y: cy };
}
return {
x: cx + dx * t,
y: cy + dy * t
};
};
// Default node width for fallback (used where node.width is not yet set)
const defaultNodeWidth = minNodeWidth;
// IMPORTANT: Create deep copies to avoid mutating the original diagram prop
// This prevents issues when switching layouts (force simulation mutates source/target to objects)
const workingNodes = diagram.nodes.map(node => ({ ...node }));
const allLinks = diagram.links.map(link => {
// Always ensure source/target are strings (force simulation may have converted them to objects)
const source = typeof link.source === 'string' ? link.source : (link.source as any).id;
const target = typeof link.target === 'string' ? link.target : (link.target as any).id;
return {
...link,
source,
target,
// Detect self-loops (same source and target)
isSelfLoop: source === target
};
});
// Log self-loop detection
const selfLoopCount = allLinks.filter(l => l.isSelfLoop).length;
if (selfLoopCount > 0) {
debugLog(`[UMLVisualization] Self-loops detected: ${selfLoopCount}`);
}
// Helper to check if link is a dual-class pattern link
const isDualClassLink = (link: typeof allLinks[0]) =>
link.label === 'linked_collection_type' || link.label === 'linked_custodian_type';
// Helper to check if link is a slot range link (aggregation/composition)
const isSlotRangeLink = (link: typeof allLinks[0]) =>
link.type === 'aggregation' || link.type === 'composition';
// Filter links based on all toggles
const workingLinks = allLinks.filter(link => {
// Filter provenance links
if (!showProvenanceLinks && link.isProvenance) return false;
// Filter inheritance links
if (!showInheritanceLinks && link.type === 'inheritance') return false;
// Filter slot range links (but not dual-class links which use association type)
if (!showSlotRangeLinks && isSlotRangeLink(link) && !isDualClassLink(link)) return false;
// Filter dual-class pattern links
if (!showDualClassLinks && isDualClassLink(link)) return false;
return true;
});
// Debug: Log link type breakdown before and after filtering
const linkTypeBreakdown = {
total: allLinks.length,
inheritance: allLinks.filter(l => l.type === 'inheritance').length,
aggregation: allLinks.filter(l => l.type === 'aggregation').length,
composition: allLinks.filter(l => l.type === 'composition').length,
association: allLinks.filter(l => l.type === 'association').length,
provenance: allLinks.filter(l => l.isProvenance).length,
dualClass: allLinks.filter(l => isDualClassLink(l)).length,
};
debugLog(`[UMLVisualization] Link breakdown BEFORE filter:`, linkTypeBreakdown);
debugLog(`[UMLVisualization] Toggle states:`, {
showInheritanceLinks,
showSlotRangeLinks,
showDualClassLinks,
showProvenanceLinks
});
debugLog(`[UMLVisualization] Links AFTER filter: ${workingLinks.length}`, {
inheritance: workingLinks.filter(l => l.type === 'inheritance').length,
aggregation: workingLinks.filter(l => l.type === 'aggregation').length,
composition: workingLinks.filter(l => l.type === 'composition').length,
association: workingLinks.filter(l => l.type === 'association').length,
});
workingNodes.forEach(node => {
// When showing attributes as edges, don't count attributes for node height
const attributeCount = showAttributesAsEdges ? 0 : (node.attributes?.length || 0);
const methodCount = node.methods?.length || 0;
// Calculate dynamic width based on content (also respects showAttributesAsEdges)
node.width = calculateNodeWidth(node, showAttributesAsEdges);
node.height = nodeHeaderHeight +
(attributeCount > 0 ? attributeCount * attributeHeight + nodePadding : 0) +
(methodCount > 0 ? methodCount * methodHeight + nodePadding : 0);
});
// When showAttributesAsEdges is true, create primitive type nodes and edges for attributes
if (showAttributesAsEdges) {
// Collect all unique primitive types from attributes
const primitiveTypes = new Set<string>();
workingNodes.forEach(node => {
node.attributes?.forEach(attr => {
// Only create nodes for primitive types (not class references which already have nodes)
const attrType = attr.type;
const existingNode = workingNodes.find(n => n.id === attrType || n.name === attrType);
if (!existingNode) {
primitiveTypes.add(attrType);
}
});
});
// Create nodes for primitive types
primitiveTypes.forEach(primitiveType => {
workingNodes.push({
id: `primitive_${primitiveType}`,
name: primitiveType,
type: 'entity' as const, // Use 'entity' type for primitive type nodes (styled differently)
attributes: [],
methods: [],
width: Math.max(minNodeWidth, primitiveType.length * 8 + 20),
height: nodeHeaderHeight,
});
});
// Create edges from classes to primitive types for each attribute
workingNodes.forEach(node => {
if (node.id.startsWith('primitive_')) return; // Skip primitive type nodes themselves
node.attributes?.forEach(attr => {
const attrType = attr.type;
// Check if target is a class node or primitive type node
const existingNode = workingNodes.find(n => n.id === attrType || n.name === attrType);
const targetId = existingNode ? existingNode.id : `primitive_${attrType}`;
workingLinks.push({
source: node.id,
target: targetId,
type: 'association' as const,
label: attr.name,
isSelfLoop: false,
isPrimitiveAttribute: true, // Mark as primitive attribute edge for potential filtering
} as any);
});
});
}
// Layout algorithm: Force simulation, Dagre grid, ELK, Circular, or Radial
let simulation: d3.Simulation<any, undefined> | null = null;
let layoutPromise: Promise<void> | null = null;
if (layoutType === 'dagre') {
// Dagre grid layout (tight, hierarchical like Mermaid)
const dagreGraph = new dagre.graphlib.Graph();
// Calculate spacing based on direction
const isVertical = dagreDirection === 'TB' || dagreDirection === 'BT';
const nodesep = isVertical ? 80 : 100; // Horizontal spacing (or vertical if LR/RL)
const ranksep = isVertical ? 120 : 150; // Vertical spacing between ranks (or horizontal if LR/RL)
dagreGraph.setGraph({
rankdir: dagreDirection, // Direction: TB, BT, LR, RL
align: dagreAlignment, // Alignment: UL, UR, DL, DR, or undefined
nodesep: nodesep, // Node spacing
ranksep: ranksep, // Rank spacing
ranker: dagreRanker, // Ranking algorithm
marginx: 50,
marginy: 50
});
dagreGraph.setDefaultEdgeLabel(() => ({}));
// Add nodes to dagre graph
workingNodes.forEach(node => {
dagreGraph.setNode(node.id, {
width: node.width || defaultNodeWidth,
height: node.height || nodeHeaderHeight
});
});
// Add edges to dagre graph (source/target are always strings now)
workingLinks.forEach(link => {
dagreGraph.setEdge(link.source as string, link.target as string);
});
// Run dagre layout
dagre.layout(dagreGraph);
// Apply computed positions to nodes
workingNodes.forEach(node => {
const dagreNode = dagreGraph.node(node.id);
if (dagreNode) {
node.x = dagreNode.x;
node.y = dagreNode.y;
// Lock positions (no physics simulation)
(node as any).fx = dagreNode.x;
(node as any).fy = dagreNode.y;
// Store position for navigation
nodePositionsRef.current.set(node.id, { x: dagreNode.x, y: dagreNode.y });
}
});
// Debug: Log sample node positions after dagre layout
debugLog('[UMLVisualization v2] ===== DAGRE LAYOUT COMPLETE =====');
const sampleNodes = workingNodes.slice(0, 3);
debugLog('[UMLVisualization v2] Sample node positions after dagre:',
sampleNodes.map(n => ({ id: n.id, x: n.x, y: n.y, width: n.width, height: n.height })));
debugLog('[UMLVisualization v2] Viewport dimensions:', { width, height });
debugLog('[UMLVisualization v2] Total nodes:', workingNodes.length, 'nodes with valid x/y:',
workingNodes.filter(n => typeof n.x === 'number' && typeof n.y === 'number').length);
// No simulation needed - positions are fixed
simulation = null;
} else if (layoutType === 'elk') {
// ELK (Eclipse Layout Kernel) - Advanced hierarchical layout
const elk = new ELK();
// Build ELK graph structure
const elkGraph = {
id: 'root',
layoutOptions: {
'elk.algorithm': elkAlgorithm,
'elk.direction': dagreDirection === 'LR' || dagreDirection === 'RL' ? 'RIGHT' : 'DOWN',
'elk.spacing.nodeNode': '80',
'elk.layered.spacing.nodeNodeBetweenLayers': '120',
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
'elk.edgeRouting': 'ORTHOGONAL',
},
children: workingNodes.map(node => ({
id: node.id,
width: node.width || defaultNodeWidth,
height: node.height || nodeHeaderHeight,
})),
edges: workingLinks.map((link, i) => ({
id: `e${i}`,
sources: [link.source as string],
targets: [link.target as string],
})),
};
// ELK layout is async - store promise for later handling
layoutPromise = elk.layout(elkGraph).then((layoutResult) => {
// Apply ELK positions to nodes
layoutResult.children?.forEach((elkNode) => {
const node = workingNodes.find(n => n.id === elkNode.id);
if (node && elkNode.x !== undefined && elkNode.y !== undefined) {
// ELK gives top-left corner, we need center
node.x = elkNode.x + (elkNode.width || 0) / 2;
node.y = elkNode.y + (elkNode.height || 0) / 2;
(node as any).fx = node.x;
(node as any).fy = node.y;
nodePositionsRef.current.set(node.id, { x: node.x, y: node.y });
}
});
debugLog('[UMLVisualization] ===== ELK LAYOUT COMPLETE =====');
}).catch(err => {
console.error('[UMLVisualization] ELK layout failed:', err);
});
simulation = null;
} else if (layoutType === 'circular') {
// Circular layout - arrange nodes in a circle
const nodeCount = workingNodes.length;
const centerX = actualWidth / 2;
const centerY = actualHeight / 2;
// Calculate radius based on node count and sizes
const avgNodeSize = workingNodes.reduce((sum, n) => sum + (n.width || defaultNodeWidth), 0) / nodeCount;
const circumference = nodeCount * (avgNodeSize + 60); // 60px spacing between nodes
const radius = Math.max(circumference / (2 * Math.PI), 300); // Minimum radius of 300
workingNodes.forEach((node, i) => {
const angle = (2 * Math.PI * i) / nodeCount - Math.PI / 2; // Start from top
node.x = centerX + radius * Math.cos(angle);
node.y = centerY + radius * Math.sin(angle);
(node as any).fx = node.x;
(node as any).fy = node.y;
nodePositionsRef.current.set(node.id, { x: node.x, y: node.y });
});
debugLog('[UMLVisualization] ===== CIRCULAR LAYOUT COMPLETE =====');
debugLog(`[UMLVisualization] Circular: ${nodeCount} nodes, radius: ${radius.toFixed(0)}px`);
simulation = null;
} else if (layoutType === 'radial') {
// Radial layout - tree radiating from center (root nodes at center)
const centerX = actualWidth / 2;
const centerY = actualHeight / 2;
// Find root nodes (nodes that are not targets of any link, or have most outgoing links)
const targetIds = new Set(workingLinks.map(l => l.target as string));
const sourceIds = new Set(workingLinks.map(l => l.source as string));
// Nodes that are sources but not targets are roots
let rootNodes = workingNodes.filter(n => sourceIds.has(n.id) && !targetIds.has(n.id));
// If no clear roots, pick nodes with most outgoing connections
if (rootNodes.length === 0) {
const outgoingCount = new Map<string, number>();
workingLinks.forEach(l => {
const src = l.source as string;
outgoingCount.set(src, (outgoingCount.get(src) || 0) + 1);
});
const maxOutgoing = Math.max(...outgoingCount.values(), 0);
rootNodes = workingNodes.filter(n => (outgoingCount.get(n.id) || 0) === maxOutgoing);
}
// If still no roots, use first node
if (rootNodes.length === 0 && workingNodes.length > 0) {
rootNodes = [workingNodes[0]];
}
// Build adjacency list (treating as directed graph from source to target)
const children = new Map<string, string[]>();
workingLinks.forEach(link => {
const src = link.source as string;
const tgt = link.target as string;
if (!children.has(src)) children.set(src, []);
children.get(src)!.push(tgt);
});
// BFS to assign levels
const levels = new Map<string, number>();
const visited = new Set<string>();
const queue: { id: string; level: number }[] = [];
rootNodes.forEach(r => {
queue.push({ id: r.id, level: 0 });
visited.add(r.id);
levels.set(r.id, 0);
});
while (queue.length > 0) {
const { id, level } = queue.shift()!;
const nodeChildren = children.get(id) || [];
nodeChildren.forEach(childId => {
if (!visited.has(childId)) {
visited.add(childId);
levels.set(childId, level + 1);
queue.push({ id: childId, level: level + 1 });
}
});
}
// Assign unvisited nodes to outermost level
const maxLevel = Math.max(...levels.values(), 0);
workingNodes.forEach(n => {
if (!levels.has(n.id)) {
levels.set(n.id, maxLevel + 1);
}
});
// Group nodes by level
const nodesByLevel = new Map<number, UMLNode[]>();
workingNodes.forEach(n => {
const level = levels.get(n.id) || 0;
if (!nodesByLevel.has(level)) nodesByLevel.set(level, []);
nodesByLevel.get(level)!.push(n);
});
// Calculate radius increment per level
const totalLevels = Math.max(...nodesByLevel.keys()) + 1;
const minRadius = 80;
const radiusIncrement = Math.max(150, (Math.min(actualWidth, actualHeight) - 200) / (totalLevels + 1));
// Position nodes in concentric circles
nodesByLevel.forEach((nodesAtLevel, level) => {
const radius = minRadius + level * radiusIncrement;
const angleStep = (2 * Math.PI) / nodesAtLevel.length;
const startAngle = -Math.PI / 2; // Start from top
nodesAtLevel.forEach((node, i) => {
const angle = startAngle + i * angleStep;
node.x = centerX + radius * Math.cos(angle);
node.y = centerY + radius * Math.sin(angle);
(node as any).fx = node.x;
(node as any).fy = node.y;
nodePositionsRef.current.set(node.id, { x: node.x, y: node.y });
});
});
debugLog('[UMLVisualization] ===== RADIAL LAYOUT COMPLETE =====');
debugLog(`[UMLVisualization] Radial: ${totalLevels} levels, ${rootNodes.length} root nodes`);
simulation = null;
} else if (layoutType === 'clustered') {
// Clustered layout - group nodes by module and layout each cluster
// Group nodes by module
const nodesByModule = new Map<string, UMLNode[]>();
workingNodes.forEach(node => {
const module = node.module || 'other';
if (!nodesByModule.has(module)) {
nodesByModule.set(module, []);
}
nodesByModule.get(module)!.push(node);
});
debugLog('[UMLVisualization] Clustered: Modules found:', Array.from(nodesByModule.keys()));
// Module display names and colors for visual distinction
const moduleColors: Record<string, { bg: string; border: string; text: string }> = {
custodian: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' }, // Blue - core
custodian_types: { bg: '#dbeafe', border: '#2563eb', text: '#1d4ed8' }, // Light Blue - types
legal: { bg: '#fce7f3', border: '#db2777', text: '#be185d' }, // Pink - legal/governance
organization: { bg: '#f3e8ff', border: '#9333ea', text: '#7c3aed' }, // Purple - org structure
location: { bg: '#dcfce7', border: '#16a34a', text: '#166534' }, // Green - geography
collections: { bg: '#f5f3ff', border: '#8b5cf6', text: '#6d28d9' }, // Violet - collections
digital: { bg: '#e0f2fe', border: '#0284c7', text: '#0369a1' }, // Sky - digital platforms
identifiers: { bg: '#fef3c7', border: '#f59e0b', text: '#b45309' }, // Amber - identifiers
provenance: { bg: '#fdf2f8', border: '#ec4899', text: '#be185d' }, // Rose - provenance
people: { bg: '#fef2f2', border: '#ef4444', text: '#b91c1c' }, // Red - people
observations: { bg: '#ecfeff', border: '#06b6d4', text: '#0e7490' }, // Cyan - observations
standards: { bg: '#f0fdfa', border: '#14b8a6', text: '#0f766e' }, // Teal - standards
enums: { bg: '#fefce8', border: '#eab308', text: '#a16207' }, // Yellow - enums
other: { bg: '#f3f4f6', border: '#6b7280', text: '#374151' }, // Gray - other
};
const moduleDisplayNames: Record<string, string> = {
custodian: 'Custodian Core',
custodian_types: 'Custodian Types',
legal: 'Legal & Governance',
organization: 'Organization Structure',
location: 'Places & Geography',
collections: 'Collections & Storage',
digital: 'Digital Platforms',
identifiers: 'Identifiers',
provenance: 'Provenance & Events',
people: 'People & Agents',
observations: 'Observations',
standards: 'Standards & Docs',
enums: 'Enumerations',
other: 'Other Classes',
};
// Calculate cluster sizes and positions
const clusters: {
module: string;
nodes: UMLNode[];
width: number;
height: number;
x: number;
y: number;
}[] = [];
const clusterPadding = 40; // Padding inside cluster box
const clusterSpacing = 60; // Space between clusters
const clusterHeaderHeight = 30; // Height for cluster title
// First pass: layout each cluster internally using dagre
nodesByModule.forEach((clusterNodes, module) => {
if (clusterNodes.length === 0) return;
// Create mini dagre graph for this cluster
const clusterGraph = new dagre.graphlib.Graph();
clusterGraph.setGraph({
rankdir: 'TB',
nodesep: 40,
ranksep: 60,
marginx: clusterPadding,
marginy: clusterPadding + clusterHeaderHeight
});
clusterGraph.setDefaultEdgeLabel(() => ({}));
// Add cluster nodes
clusterNodes.forEach(node => {
clusterGraph.setNode(node.id, {
width: node.width || defaultNodeWidth,
height: node.height || nodeHeaderHeight
});
});
// Add edges that are internal to this cluster
const clusterNodeIds = new Set(clusterNodes.map(n => n.id));
workingLinks.forEach(link => {
const sourceId = link.source as string;
const targetId = link.target as string;
if (clusterNodeIds.has(sourceId) && clusterNodeIds.has(targetId)) {
clusterGraph.setEdge(sourceId, targetId);
}
});
// Run dagre layout for this cluster
dagre.layout(clusterGraph);
// Get cluster bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
clusterNodes.forEach(node => {
const dagreNode = clusterGraph.node(node.id);
if (dagreNode) {
// Store relative position within cluster
node.x = dagreNode.x;
node.y = dagreNode.y;
const halfW = (node.width || defaultNodeWidth) / 2;
const halfH = (node.height || nodeHeaderHeight) / 2;
minX = Math.min(minX, dagreNode.x - halfW);
minY = Math.min(minY, dagreNode.y - halfH);
maxX = Math.max(maxX, dagreNode.x + halfW);
maxY = Math.max(maxY, dagreNode.y + halfH);
}
});
// Calculate cluster dimensions
const clusterWidth = maxX - minX + clusterPadding * 2;
const clusterHeight = maxY - minY + clusterPadding * 2 + clusterHeaderHeight;
// Normalize node positions to start from (0,0) within cluster
clusterNodes.forEach(node => {
if (node.x !== undefined && node.y !== undefined) {
node.x = node.x - minX + clusterPadding;
node.y = node.y - minY + clusterPadding + clusterHeaderHeight;
}
});
clusters.push({
module,
nodes: clusterNodes,
width: clusterWidth,
height: clusterHeight,
x: 0, // Will be set in second pass
y: 0
});
});
// Sort clusters by size (larger clusters first)
clusters.sort((a, b) => (b.width * b.height) - (a.width * a.height));
// Second pass: position clusters in a grid pattern
const gridCols = Math.ceil(Math.sqrt(clusters.length));
let currentX = clusterSpacing;
let currentY = clusterSpacing;
let rowMaxHeight = 0;
let col = 0;
clusters.forEach((cluster, _index) => {
// Check if we need to start a new row
if (col >= gridCols) {
col = 0;
currentX = clusterSpacing;
currentY += rowMaxHeight + clusterSpacing;
rowMaxHeight = 0;
}
// Position cluster
cluster.x = currentX;
cluster.y = currentY;
// Apply cluster offset to all nodes
cluster.nodes.forEach(node => {
if (node.x !== undefined && node.y !== undefined) {
node.x += cluster.x;
node.y += cluster.y;
(node as any).fx = node.x;
(node as any).fy = node.y;
nodePositionsRef.current.set(node.id, { x: node.x, y: node.y });
}
});
// Update position tracking
currentX += cluster.width + clusterSpacing;
rowMaxHeight = Math.max(rowMaxHeight, cluster.height);
col++;
});
// Store cluster data for rendering backgrounds
(g as any).__clusters = clusters.map(c => ({
module: c.module,
x: c.x,
y: c.y,
width: c.width,
height: c.height,
colors: moduleColors[c.module] || moduleColors.other,
displayName: moduleDisplayNames[c.module] || c.module
}));
debugLog('[UMLVisualization] ===== CLUSTERED LAYOUT COMPLETE =====');
debugLog(`[UMLVisualization] Clustered: ${clusters.length} clusters positioned`);
simulation = null;
} else {
// Force simulation for layout (original scattered physics-based layout)
// PERFORMANCE OPTIMIZED: Reduced charge strength and added distance cap + faster convergence
simulation = d3.forceSimulation(workingNodes as any)
.force('link', d3.forceLink(workingLinks)
.id((d: any) => d.id)
.distance(200) // Slightly reduced from 250
.strength(0.4)) // Slightly reduced from 0.5 for smoother settling
.force('charge', d3.forceManyBody()
.strength(-500) // Reduced from -1000 (less repulsion = faster)
.distanceMax(400)) // Cap the distance for O(n) instead of O(n²) at far distances
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(120)) // Slightly reduced from 150
.alphaDecay(0.04) // Faster decay (default 0.0228) - settles ~2x faster
.velocityDecay(0.5); // More damping for smoother animation (default 0.4)
}
// Create arrow markers for different relationship types with highlighted versions
const defs = g.append('defs');
// Define arrow types with both normal and highlighted versions
// Redesigned for cleaner, more professional UML appearance
// size array: [markerWidth, markerHeight, refX, refY]
// refX is set to the TIP of the arrow so the arrow tip touches the node boundary
// The arrow's opaque fill covers the line that passes through to the refX point
const arrowTypes = [
// Inheritance: Hollow equilateral triangle pointing right (extends/is_a relationship)
// Path: M0,0 L12,6 L0,12 z - triangle with tip at x=12, back at x=0
// refX=12 places tip at node boundary, white fill covers the line
{ id: 'inheritance', path: 'M0,0 L12,6 L0,12 z', fill: 'white', stroke: '#6366f1', strokeWidth: 1.5, size: [14, 14, 12, 6] },
// Composition: Filled diamond (strong ownership - child cannot exist without parent)
// Path: M0,6 L6,0 L12,6 L6,12 z - diamond with rightmost at x=12, leftmost at x=0
// refX=12 places right point at node boundary, filled diamond covers the line
{ id: 'composition', path: 'M0,6 L6,0 L12,6 L6,12 z', fill: '#6366f1', stroke: '#6366f1', strokeWidth: 1, size: [14, 14, 12, 6] },
// Aggregation: Hollow diamond (weak ownership - child can exist independently)
// Path: M0,6 L6,0 L12,6 L6,12 z - diamond with rightmost at x=12, leftmost at x=0
// refX=12 places right point at node boundary, white fill covers the line
{ id: 'aggregation', path: 'M0,6 L6,0 L12,6 L6,12 z', fill: 'white', stroke: '#6366f1', strokeWidth: 1.5, size: [14, 14, 12, 6] },
// Association: Simple arrow (reference relationship)
// Path: M0,0 L10,5 L0,10 L3,5 z - arrow with tip at x=10, back notch at x=3
// refX=10 places tip at node boundary, filled arrow covers the line
{ id: 'association', path: 'M0,0 L10,5 L0,10 L3,5 z', fill: '#6366f1', stroke: '#6366f1', strokeWidth: 0.5, size: [12, 12, 10, 5] },
];
arrowTypes.forEach(arrow => {
// Normal state arrow (semi-transparent)
const marker = defs.append('marker')
.attr('id', `arrow-${arrow.id}`)
.attr('markerWidth', arrow.size[0])
.attr('markerHeight', arrow.size[1])
.attr('refX', arrow.size[2])
.attr('refY', arrow.size[3])
.attr('orient', 'auto');
const path = marker.append('path')
.attr('d', arrow.path)
.attr('fill', arrow.fill)
.attr('opacity', 0.8);
if (arrow.stroke) {
path.attr('stroke', arrow.stroke).attr('stroke-width', arrow.strokeWidth);
}
// Highlighted state arrow (fully opaque, larger)
const highlightMarker = defs.append('marker')
.attr('id', `arrow-${arrow.id}-highlight`)
.attr('markerWidth', arrow.size[0] * 1.3)
.attr('markerHeight', arrow.size[1] * 1.3)
.attr('refX', arrow.size[2])
.attr('refY', arrow.size[3])
.attr('orient', 'auto');
const highlightPath = highlightMarker.append('path')
.attr('d', arrow.path)
.attr('fill', arrow.fill)
.attr('opacity', 1);
if (arrow.stroke) {
highlightPath.attr('stroke', arrow.stroke).attr('stroke-width', arrow.strokeWidth * 1.2);
}
});
// Cardinality marker definitions for UML and Crow's Foot notation
// Using indigo color scheme (#6366f1) for consistency with arrows
const cardinalityColor = '#6366f1';
const cardinalityColorDark = '#4f46e5';
const cardinalityMarkerSize = 10;
// Filled circle (required one) - UML style
defs.append('marker')
.attr('id', 'card-one-required')
.attr('markerWidth', cardinalityMarkerSize)
.attr('markerHeight', cardinalityMarkerSize)
.attr('refX', cardinalityMarkerSize / 2)
.attr('refY', cardinalityMarkerSize / 2)
.attr('orient', 'auto')
.append('circle')
.attr('cx', cardinalityMarkerSize / 2)
.attr('cy', cardinalityMarkerSize / 2)
.attr('r', 4)
.attr('fill', cardinalityColor)
.attr('stroke', cardinalityColorDark)
.attr('stroke-width', 1);
// Open circle (optional one) - UML style
defs.append('marker')
.attr('id', 'card-one-optional')
.attr('markerWidth', cardinalityMarkerSize)
.attr('markerHeight', cardinalityMarkerSize)
.attr('refX', cardinalityMarkerSize / 2)
.attr('refY', cardinalityMarkerSize / 2)
.attr('orient', 'auto')
.append('circle')
.attr('cx', cardinalityMarkerSize / 2)
.attr('cy', cardinalityMarkerSize / 2)
.attr('r', 4)
.attr('fill', 'white')
.attr('stroke', cardinalityColor)
.attr('stroke-width', 2);
// Diamond (many required) - UML style
defs.append('marker')
.attr('id', 'card-many-required')
.attr('markerWidth', 12)
.attr('markerHeight', cardinalityMarkerSize)
.attr('refX', 6)
.attr('refY', cardinalityMarkerSize / 2)
.attr('orient', 'auto')
.append('path')
.attr('d', `M0,${cardinalityMarkerSize/2} L6,0 L12,${cardinalityMarkerSize/2} L6,${cardinalityMarkerSize} Z`)
.attr('fill', cardinalityColor)
.attr('stroke', cardinalityColorDark)
.attr('stroke-width', 1);
// Open diamond (many optional) - UML style
defs.append('marker')
.attr('id', 'card-many-optional')
.attr('markerWidth', 12)
.attr('markerHeight', cardinalityMarkerSize)
.attr('refX', 6)
.attr('refY', cardinalityMarkerSize / 2)
.attr('orient', 'auto')
.append('path')
.attr('d', `M0,${cardinalityMarkerSize/2} L6,0 L12,${cardinalityMarkerSize/2} L6,${cardinalityMarkerSize} Z`)
.attr('fill', 'white')
.attr('stroke', cardinalityColor)
.attr('stroke-width', 2);
// Crow's Foot markers
// Bar (exactly one)
defs.append('marker')
.attr('id', 'crowsfoot-one')
.attr('markerWidth', 12)
.attr('markerHeight', 14)
.attr('refX', 6)
.attr('refY', 7)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M6,1 L6,13')
.attr('stroke', cardinalityColor)
.attr('stroke-width', 2.5)
.attr('stroke-linecap', 'round')
.attr('fill', 'none');
// Circle + Bar (zero or one)
const zeroOneMarker = defs.append('marker')
.attr('id', 'crowsfoot-zero-one')
.attr('markerWidth', 18)
.attr('markerHeight', 14)
.attr('refX', 10)
.attr('refY', 7)
.attr('orient', 'auto');
zeroOneMarker.append('circle')
.attr('cx', 5)
.attr('cy', 7)
.attr('r', 4)
.attr('fill', 'white')
.attr('stroke', cardinalityColor)
.attr('stroke-width', 2);
zeroOneMarker.append('path')
.attr('d', 'M12,1 L12,13')
.attr('stroke', cardinalityColor)
.attr('stroke-width', 2.5)
.attr('stroke-linecap', 'round')
.attr('fill', 'none');
// Crow's foot (one or more)
defs.append('marker')
.attr('id', 'crowsfoot-one-many')
.attr('markerWidth', 16)
.attr('markerHeight', 16)
.attr('refX', 10)
.attr('refY', 8)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M2,1 L12,8 L2,15 M12,1 L12,15')
.attr('stroke', cardinalityColor)
.attr('stroke-width', 2)
.attr('stroke-linecap', 'round')
.attr('stroke-linejoin', 'round')
.attr('fill', 'none');
// Circle + Crow's foot (zero or more)
const zeroManyMarker = defs.append('marker')
.attr('id', 'crowsfoot-zero-many')
.attr('markerWidth', 22)
.attr('markerHeight', 16)
.attr('refX', 14)
.attr('refY', 8)
.attr('orient', 'auto');
zeroManyMarker.append('circle')
.attr('cx', 5)
.attr('cy', 8)
.attr('r', 4)
.attr('fill', 'white')
.attr('stroke', cardinalityColor)
.attr('stroke-width', 2);
zeroManyMarker.append('path')
.attr('d', 'M10,1 L20,8 L10,15')
.attr('stroke', cardinalityColor)
.attr('stroke-width', 2)
.attr('stroke-linecap', 'round')
.attr('stroke-linejoin', 'round')
.attr('stroke-width', 1.5)
.attr('fill', 'none');
// Initialize link state on working copies (parser now handles bidirectional detection)
workingLinks.forEach(link => {
// Keep bidirectional and isReversed from parser, with defaults
link.bidirectional = link.bidirectional || false;
link.isReversed = link.isReversed || false;
});
// Debug: Log node and link counts
const bidirectionalCount = workingLinks.filter(l => l.bidirectional).length;
debugLog(`[UMLVisualization] Nodes: ${workingNodes.length}, Total links: ${workingLinks.length}, Bidirectional: ${bidirectionalCount}`);
// Debug: Check if nodes have positions
const nodesWithPositions = workingNodes.filter(n => n.x !== undefined && n.y !== undefined).length;
debugLog(`[UMLVisualization] Nodes with positions: ${nodesWithPositions}/${workingNodes.length}`);
// Draw cluster backgrounds for clustered layout (rendered first so they appear behind everything)
if (layoutType === 'clustered' && (g as any).__clusters) {
const clusterData = (g as any).__clusters as {
module: string;
x: number;
y: number;
width: number;
height: number;
colors: { bg: string; border: string; text: string };
displayName: string;
}[];
const clusterGroups = g.append('g')
.attr('class', 'clusters')
.selectAll('g')
.data(clusterData)
.join('g')
.attr('class', 'cluster-group');
// Cluster background rectangle
clusterGroups.append('rect')
.attr('class', 'cluster-bg')
.attr('x', d => d.x)
.attr('y', d => d.y)
.attr('width', d => d.width)
.attr('height', d => d.height)
.attr('rx', 12)
.attr('ry', 12)
.attr('fill', d => d.colors.bg)
.attr('stroke', d => d.colors.border)
.attr('stroke-width', 2)
.attr('stroke-opacity', 0.6)
.attr('fill-opacity', 0.5);
// Cluster title
clusterGroups.append('text')
.attr('class', 'cluster-title')
.attr('x', d => d.x + 12)
.attr('y', d => d.y + 20)
.attr('fill', d => d.colors.text)
.attr('font-size', '13px')
.attr('font-weight', '600')
.text(d => d.displayName);
// Cluster node count badge
clusterGroups.append('text')
.attr('class', 'cluster-count')
.attr('x', d => d.x + d.width - 12)
.attr('y', d => d.y + 20)
.attr('text-anchor', 'end')
.attr('fill', d => d.colors.text)
.attr('font-size', '11px')
.attr('font-weight', '500')
.attr('opacity', 0.7)
.text(d => {
const count = workingNodes.filter(n => (n.module || 'other') === d.module).length;
return `${count} ${count === 1 ? 'class' : 'classes'}`;
});
}
// Draw links first (edges between nodes)
const links = g.append('g')
.attr('class', 'links')
.selectAll('g')
.data(workingLinks)
.join('g')
.attr('class', 'link-group');
// Helper to check if this is an ER-type relationship with cardinality
const isERRelationship = (type: string) =>
type === 'one-to-one' || type === 'one-to-many' ||
type === 'many-to-one' || type === 'many-to-many';
// Helper function to get link stroke color
const getLinkStroke = (d: any) => {
if (d.isProvenance) return '#9ca3af'; // Gray for provenance
if (d.bidirectional) return '#6366f1'; // Indigo for bidirectional
return '#0a3dfa'; // Blue for normal
};
// Helper function to get link classes
const getLinkClass = (d: any) => {
let classes = `link link-${d.type}`;
if (d.bidirectional) classes += ' link-bidirectional';
if (d.isProvenance) classes += ' link-provenance';
if (isERRelationship(d.type)) classes += ' link-er';
if (d.isSelfLoop) classes += ' link-self-loop';
return classes;
};
// Helper function to get link dash array
const getLinkDashArray = (d: any) => {
if (d.isProvenance) return '2,2'; // Dotted for provenance
if (d.bidirectional) return '5,3'; // Dashed for bidirectional
return 'none';
};
// Helper function to get marker-end
const getLinkMarkerEnd = (d: any) => {
// For ER relationships with cardinality, use cardinality markers instead of arrows
if (isERRelationship(d.type) && d.cardinality) {
const parsed = parseCardinality(d.cardinality);
return `url(#${getCardinalityMarkerId(parsed.target, localCardinalityStyle)})`;
}
// For class diagram relationships, use arrow markers
const arrowType = isERRelationship(d.type) ? 'association' : d.type;
return `url(#arrow-${arrowType})`;
};
// Helper function to get marker-start
const getLinkMarkerStart = (d: any) => {
// For ER relationships, add source cardinality marker
if (isERRelationship(d.type) && d.cardinality) {
const parsed = parseCardinality(d.cardinality);
return `url(#${getCardinalityMarkerId(parsed.source, localCardinalityStyle)})`;
}
return null;
};
// Append path elements for self-loops (curved arcs)
links.filter((d: any) => d.isSelfLoop)
.append('path')
.attr('class', getLinkClass)
.attr('stroke', getLinkStroke)
.attr('stroke-width', 2)
.attr('stroke-opacity', (d: any) => d.isProvenance ? 0.6 : 0.7)
.attr('stroke-dasharray', getLinkDashArray)
.attr('fill', 'none')
.attr('marker-end', getLinkMarkerEnd)
.attr('marker-start', getLinkMarkerStart)
.style('cursor', (d: any) => d.bidirectional ? 'pointer' : 'default');
// Append line elements for regular links (not self-loops)
links.filter((d: any) => !d.isSelfLoop)
.append('line')
.attr('class', getLinkClass)
.attr('stroke', getLinkStroke)
.attr('stroke-width', 2)
.attr('stroke-opacity', (d: any) => d.isProvenance ? 0.6 : 0.7)
.attr('stroke-dasharray', getLinkDashArray)
.attr('marker-end', getLinkMarkerEnd)
.attr('marker-start', getLinkMarkerStart)
.style('cursor', (d: any) => d.bidirectional ? 'pointer' : 'default');
// Apply event handlers to all link elements (both lines and paths)
links.select('line, path')
.on('click', function(event, d: any) {
// Toggle direction for bidirectional edges on left-click
if (!d.bidirectional) return;
event.stopPropagation(); // Prevent triggering svg click (deselect)
// Toggle reversed state
d.isReversed = !d.isReversed;
// Swap source and target for line direction
const temp = d.source;
d.source = d.target;
d.target = temp;
// Update the label text to show the correct direction
const linkIndex = workingLinks.indexOf(d);
const labelSelection = linkLabels.filter((_: any, i: number) => i === linkIndex);
// Get the appropriate label based on current direction
const currentLabel = d.isReversed ? (d.reverseLabel || d.label) : d.label;
const currentCardinality = d.isReversed ? (d.reverseCardinality || d.cardinality) : d.cardinality;
let displayLabel = currentLabel || '';
if (currentCardinality) {
displayLabel = displayLabel ? `${displayLabel} [${currentCardinality}]` : currentCardinality;
}
labelSelection.text(displayLabel);
// Visual feedback - flash the edge
const arrowType = isERRelationship(d.type) ? 'association' : d.type;
// Determine correct marker based on relationship type and current cardinality
let flashMarkerEnd: string;
let restoreMarkerEnd: string;
if (isERRelationship(d.type) && currentCardinality) {
const parsed = parseCardinality(currentCardinality);
// Use same cardinality marker during flash (no highlight variant for cardinality markers)
flashMarkerEnd = `url(#${getCardinalityMarkerId(parsed.target, localCardinalityStyle)})`;
restoreMarkerEnd = flashMarkerEnd;
} else {
flashMarkerEnd = `url(#arrow-${arrowType}-highlight)`;
restoreMarkerEnd = `url(#arrow-${arrowType})`;
}
d3.select(this)
.attr('marker-end', flashMarkerEnd)
.transition()
.duration(300)
.attr('stroke-width', 4)
.attr('stroke', '#22c55e') // Green flash on toggle
.transition()
.duration(300)
.attr('stroke-width', 2)
.attr('stroke', '#6366f1') // Back to indigo
.attr('marker-end', restoreMarkerEnd);
// Also update marker-start for ER relationships
if (isERRelationship(d.type) && currentCardinality) {
const parsed = parseCardinality(currentCardinality);
d3.select(this).attr('marker-start', `url(#${getCardinalityMarkerId(parsed.source, localCardinalityStyle)})`);
}
// Flash the label then hide it again
labelSelection
.transition()
.duration(200)
.style('opacity', 1)
.attr('font-weight', 'bold')
.attr('fill', '#22c55e') // Green text on toggle
.transition()
.delay(500)
.duration(300)
.style('opacity', 0) // Hide label again after flash
.attr('font-weight', 'normal')
.attr('fill', '#172a59');
// Restart simulation if using force layout, or update positions for dagre
if (simulation) {
simulation.alpha(0.3).restart();
} else {
// For dagre, manually update the link positions
updateLinkPositions();
}
})
.on('mouseenter', function(event, d: any) {
// Highlight edge on hover
const arrowType = isERRelationship(d.type) ? 'association' : d.type;
const currentCardinality = d.isReversed ? (d.reverseCardinality || d.cardinality) : d.cardinality;
// Track hovered edge info for legend highlighting
setHoveredEdgeInfo({
relationshipType: arrowType,
cardinality: currentCardinality || null
});
// Determine correct marker based on relationship type
let highlightMarkerEnd: string;
if (isERRelationship(d.type) && currentCardinality) {
const parsed = parseCardinality(currentCardinality);
// Keep cardinality marker during hover (no highlight variant)
highlightMarkerEnd = `url(#${getCardinalityMarkerId(parsed.target, localCardinalityStyle)})`;
} else {
highlightMarkerEnd = `url(#arrow-${arrowType}-highlight)`;
}
d3.select(this)
.transition()
.duration(200)
.attr('stroke-opacity', 1)
.attr('stroke-width', 3)
.attr('marker-end', highlightMarkerEnd);
// Build label text for tooltip
const currentLabel = d.isReversed ? (d.reverseLabel || d.label) : d.label;
let displayLabel = currentLabel || '';
if (currentCardinality) {
const cardinalityDisplay = formatCardinalityForTooltip(currentCardinality, localCardinalityStyle);
displayLabel = displayLabel ? `${displayLabel} ${cardinalityDisplay}` : cardinalityDisplay;
}
// Show floating tooltip near mouse position (if there's a label)
if (displayLabel) {
const containerRect = containerRef.current?.getBoundingClientRect();
if (containerRect) {
setEdgeTooltip({
label: displayLabel,
x: event.clientX - containerRect.left,
y: event.clientY - containerRect.top,
bidirectional: d.bidirectional || false,
containerWidth: containerRect.width,
containerHeight: containerRect.height
});
}
}
})
.on('mousemove', function(event, d: any) {
// Update tooltip position as mouse moves along the edge
const currentLabel = d.isReversed ? (d.reverseLabel || d.label) : d.label;
const currentCardinality = d.isReversed ? (d.reverseCardinality || d.cardinality) : d.cardinality;
let displayLabel = currentLabel || '';
if (currentCardinality) {
const cardinalityDisplay = formatCardinalityForTooltip(currentCardinality, localCardinalityStyle);
displayLabel = displayLabel ? `${displayLabel} ${cardinalityDisplay}` : cardinalityDisplay;
}
if (displayLabel) {
const containerRect = containerRef.current?.getBoundingClientRect();
if (containerRect) {
setEdgeTooltip({
label: displayLabel,
x: event.clientX - containerRect.left,
y: event.clientY - containerRect.top,
bidirectional: d.bidirectional || false,
containerWidth: containerRect.width,
containerHeight: containerRect.height
});
}
}
})
.on('mouseleave', function(_event, d: any) {
// Reset edge appearance
const arrowType = isERRelationship(d.type) ? 'association' : d.type;
// Restore appropriate marker based on relationship type
let markerEnd: string;
if (isERRelationship(d.type) && d.cardinality) {
const parsed = parseCardinality(d.cardinality);
markerEnd = `url(#${getCardinalityMarkerId(parsed.target, localCardinalityStyle)})`;
} else {
markerEnd = `url(#arrow-${arrowType})`;
}
d3.select(this)
.transition()
.duration(200)
.attr('stroke-opacity', d.isProvenance ? 0.6 : 0.7)
.attr('stroke-width', 2)
.attr('marker-end', markerEnd);
// Hide tooltip
setEdgeTooltip(null);
// Clear hovered edge info for legend highlighting
setHoveredEdgeInfo(null);
});
// Add link labels with background for readability
// First add background rects (will be positioned in updateLinkPositions)
links.append('rect')
.attr('class', 'link-label-bg')
.attr('rx', 3)
.attr('ry', 3)
.attr('fill', 'white')
.attr('stroke', '#e5e7eb')
.attr('stroke-width', 1)
.style('opacity', 0) // HIDDEN by default
.style('pointer-events', 'none');
// Add link labels - HIDDEN by default (opacity: 0), shown on hover to prevent overlap
const linkLabels = links.append('text')
.attr('class', (d) => `link-label${d.bidirectional ? ' link-label-bidirectional' : ''}`)
.attr('text-anchor', 'middle')
.attr('dy', -5)
.attr('fill', '#172a59')
.attr('font-size', '11px')
.attr('font-weight', '500')
.style('opacity', 0) // HIDDEN by default - prevents label overlap
.style('pointer-events', 'none') // Don't interfere with click events
.text((d) => {
// Show label based on current direction (isReversed state)
const currentLabel = d.isReversed ? (d.reverseLabel || d.label) : d.label;
const currentCardinality = d.isReversed ? (d.reverseCardinality || d.cardinality) : d.cardinality;
let label = currentLabel || '';
if (currentCardinality) {
label = label ? `${label} [${currentCardinality}]` : currentCardinality;
}
return label;
});
// Draw nodes (on top of links so boxes appear above edge lines)
const nodes = g.append('g')
.attr('class', 'nodes')
.selectAll('g')
.data(workingNodes)
.join('g')
.attr('class', (d) => `node node-${d.type}`)
.attr('transform', (d: any) => {
// Set initial transform - dagre has already set x,y; force layout uses (0,0) initially
const x = d.x || width / 2;
const y = d.y || height / 2;
return `translate(${x - (d.width || defaultNodeWidth) / 2}, ${y - (d.height || nodeHeaderHeight) / 2})`;
})
.style('cursor', 'pointer') // Show pointer on hover
.on('click', (event, d) => {
event.stopPropagation();
debugLog('[UMLVisualization] Node clicked:', d.name);
// Left-click now does both: show popup AND focus edges
setSelectedNode(d);
// Toggle focus: if same node is clicked again, clear focus (but still show popup)
setFocusedNodeId(prevId => prevId === d.id ? null : d.id);
})
.call(d3.drag<any, any>()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended) as any);
debugLog('[UMLVisualization v2] Nodes D3 selection created:', nodes.size());
// Node background
nodes.append('rect')
.attr('class', 'node-rect')
.attr('width', (d) => d.width || defaultNodeWidth)
.attr('height', (d) => d.height || nodeHeaderHeight)
.attr('rx', 8)
.attr('fill', 'white')
.attr('stroke', (d) => d.type === 'enum' ? '#ffc107' : d.type === 'slot' ? '#10b981' : '#0a3dfa')
.attr('stroke-width', 2);
// Node header background
nodes.append('rect')
.attr('class', 'node-header')
.attr('width', (d) => d.width || defaultNodeWidth)
.attr('height', nodeHeaderHeight)
.attr('rx', 8)
.attr('fill', (d) => d.type === 'enum' ? '#fef3c7' : d.type === 'slot' ? '#d1fae5' : '#e0e7ff') // Light amber for enum, light emerald for slot, light indigo for class
.attr('opacity', 1);
// Node name
nodes.append('text')
.attr('class', 'node-name')
.attr('x', (d) => (d.width || defaultNodeWidth) / 2)
.attr('y', nodeHeaderHeight / 2)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('fill', (d) => d.type === 'enum' ? '#92400e' : d.type === 'slot' ? '#065f46' : '#312e81') // Dark amber for enum, dark emerald for slot, dark indigo for class
.attr('font-weight', 'bold')
.attr('font-size', '14px')
.text((d) => d.name);
// Node type badge
nodes.append('text')
.attr('class', 'node-type')
.attr('x', 8)
.attr('y', 12)
.attr('fill', (d) => d.type === 'enum' ? '#b45309' : d.type === 'slot' ? '#047857' : '#4338ca') // Matching darker tones
.attr('font-size', '10px')
.attr('font-style', 'italic')
.text((d) => `«${d.type}»`);
// Draw attributes section (skip if showing attributes as edges)
if (!showAttributesAsEdges) {
nodes.each(function(d) {
if (!d.attributes || d.attributes.length === 0) return;
const nodeGroup = d3.select(this);
let yOffset = nodeHeaderHeight + nodePadding;
// Attributes divider
nodeGroup.append('line')
.attr('x1', 0)
.attr('y1', nodeHeaderHeight)
.attr('x2', d.width || defaultNodeWidth)
.attr('y2', nodeHeaderHeight)
.attr('stroke', '#0a3dfa')
.attr('stroke-width', 1);
// Attribute entries
d.attributes.forEach((attr, i) => {
nodeGroup.append('text')
.attr('class', 'node-attribute')
.attr('x', 10)
.attr('y', yOffset + i * attributeHeight)
.attr('fill', '#172a59')
.attr('font-size', '12px')
.attr('font-family', "'Monaco', 'Courier New', monospace")
.text(`${attr.name}: ${attr.type}`);
});
yOffset += d.attributes.length * attributeHeight;
});
}
// Draw methods section
nodes.each(function(d) {
if (!d.methods || d.methods.length === 0) return;
const nodeGroup = d3.select(this);
const attributeCount = d.attributes?.length || 0;
let yOffset = nodeHeaderHeight + nodePadding +
(attributeCount > 0 ? attributeCount * attributeHeight + nodePadding : 0);
// Methods divider
nodeGroup.append('line')
.attr('x1', 0)
.attr('y1', yOffset - nodePadding)
.attr('x2', d.width || defaultNodeWidth)
.attr('y2', yOffset - nodePadding)
.attr('stroke', '#0a3dfa')
.attr('stroke-width', 1);
// Method entries
d.methods.forEach((method, i) => {
const methodText = method.returnType
? `${method.name}(): ${method.returnType}`
: `${method.name}()`;
nodeGroup.append('text')
.attr('class', 'node-method')
.attr('x', 10)
.attr('y', yOffset + i * methodHeight)
.attr('fill', '#172a59')
.attr('font-size', '12px')
.attr('font-family', "'Monaco', 'Courier New', monospace")
.text(methodText);
});
});
// Shared function to update all link positions - ensures edges stay attached to box borders
const updateLinkPositions = () => {
// Build node lookup map for O(1) access
const nodeMap = new Map<string, UMLNode>();
workingNodes.forEach(n => nodeMap.set(n.id, n));
links.each(function(linkData: any) {
// Get source and target nodes - handle both string IDs and object references
let source: UMLNode | undefined;
let target: UMLNode | undefined;
if (typeof linkData.source === 'string') {
source = nodeMap.get(linkData.source);
target = nodeMap.get(linkData.target as string);
} else {
// Force simulation converts source/target to node objects
source = linkData.source as UMLNode;
target = linkData.target as UMLNode;
}
if (!source || !target || source.x === undefined || target.x === undefined) return;
const sourceW = source.width || defaultNodeWidth;
const sourceH = source.height || nodeHeaderHeight;
const targetW = target.width || defaultNodeWidth;
const targetH = target.height || nodeHeaderHeight;
const linkGroup = d3.select(this);
// Handle self-loops differently - draw a curved arc on the right side
if (linkData.isSelfLoop) {
// Self-loop: draw an arc from right side of node, looping back
const loopRadius = 35; // Size of the loop
const loopOffset = 10; // How far from edge the loop starts
// Start point: right side of node, slightly above center
const startX = source.x + sourceW / 2;
const startY = source.y! - loopOffset;
// End point: right side of node, slightly below center
const endX = source.x + sourceW / 2;
const endY = source.y! + loopOffset;
// Control points for the cubic bezier curve
const controlX = startX + loopRadius * 2.5; // How far right the loop extends
const controlY1 = startY - loopRadius; // Upper control point
const controlY2 = endY + loopRadius; // Lower control point
// SVG path for a smooth self-loop arc
const pathD = `M ${startX} ${startY}
C ${controlX} ${controlY1},
${controlX} ${controlY2},
${endX} ${endY}`;
linkGroup.select('path')
.attr('d', pathD);
// Position label to the right of the loop
const labelX = startX + loopRadius * 1.8;
const labelY = source.y!;
const textElement = linkGroup.select('text');
textElement
.attr('x', labelX)
.attr('y', labelY);
// Update background rect position and size based on text bounds
const textNode = textElement.node() as SVGTextElement | null;
if (textNode) {
const bbox = textNode.getBBox();
const padding = 4;
linkGroup.select('.link-label-bg')
.attr('x', bbox.x - padding)
.attr('y', bbox.y - padding)
.attr('width', bbox.width + padding * 2)
.attr('height', bbox.height + padding * 2);
}
return; // Done with self-loop
}
// Regular link (not self-loop) - calculate border intersections
const sourceIntersection = getBoxBorderIntersection(
source.x, source.y!, sourceW, sourceH, target.x, target.y!
);
const targetIntersection = getBoxBorderIntersection(
target.x, target.y!, targetW, targetH, source.x, source.y!
);
// Update line - set all attributes together for consistency
linkGroup.select('line')
.attr('x1', sourceIntersection.x)
.attr('y1', sourceIntersection.y)
.attr('x2', targetIntersection.x)
.attr('y2', targetIntersection.y);
// Update label position at midpoint
const midX = (source.x + target.x) / 2;
const midY = (source.y! + target.y!) / 2;
const textElement = linkGroup.select('text');
textElement
.attr('x', midX)
.attr('y', midY);
// Update background rect position and size based on text bounds
const textNode = textElement.node() as SVGTextElement | null;
if (textNode) {
const bbox = textNode.getBBox();
const padding = 4;
linkGroup.select('.link-label-bg')
.attr('x', bbox.x - padding)
.attr('y', bbox.y - padding)
.attr('width', bbox.width + padding * 2)
.attr('height', bbox.height + padding * 2);
}
});
};
// Shared function to update all node positions
const updateNodePositions = () => {
nodes.attr('transform', (d: any) =>
`translate(${d.x - (d.width || defaultNodeWidth) / 2}, ${d.y - (d.height || nodeHeaderHeight) / 2})`
);
// Store positions for navigation (used by search)
workingNodes.forEach(node => {
if (node.x !== undefined && node.y !== undefined) {
nodePositionsRef.current.set(node.id, { x: node.x, y: node.y });
}
});
};
// Update positions on tick (force simulation) or immediately (dagre/circular/radial)
if (simulation) {
simulation.on('tick', () => {
updateNodePositions();
updateLinkPositions();
});
// Auto-fit after simulation settles (for new diagrams only)
if (isNewDiagram) {
simulation.on('end', () => {
// Delay slightly to ensure DOM is fully updated
debugLog('[UMLVisualization] Force simulation ended, scheduling auto-fit');
autoFitTimeoutRef.current = window.setTimeout(() => {
debugLog('[UMLVisualization] Executing instant auto-fit after force simulation');
handleFitToScreen(0, true); // instant = true for initial load
}, 150);
});
}
} else if (layoutPromise) {
// ELK layout is async - wait for promise then update positions
layoutPromise.then(() => {
updateNodePositions();
updateLinkPositions();
debugLog('[UMLVisualization] After ELK render - updating positions');
// Auto-fit to screen for new diagrams
if (isNewDiagram) {
debugLog('[UMLVisualization] Scheduling auto-fit for new ELK diagram');
autoFitTimeoutRef.current = window.setTimeout(() => {
debugLog('[UMLVisualization] Executing instant auto-fit after ELK');
handleFitToScreen(0, true); // instant = true for initial load
}, 150);
}
});
} else {
// Dagre/Circular/Radial layout - positions are already computed, update immediately
updateNodePositions();
updateLinkPositions();
// Debug: Check what's actually rendered
const gNode = g.node();
debugLog('[UMLVisualization] After layout render - g element children:', gNode?.children.length);
debugLog('[UMLVisualization] After layout render - SVG innerHTML length:', svgRef.current?.innerHTML.length);
// Debug: Check actual node transforms
nodes.each(function(d: any) {
const transform = d3.select(this).attr('transform');
debugLog(`[UMLVisualization] Node ${d.id} transform:`, transform, 'data x/y:', d.x, d.y);
});
// Debug: Check bounding box immediately
const bbox = gNode?.getBBox();
debugLog('[UMLVisualization] Immediate getBBox:', bbox);
// Auto-fit to screen for new diagrams (delay to ensure DOM is ready)
if (isNewDiagram) {
debugLog('[UMLVisualization] Scheduling auto-fit for new diagram');
autoFitTimeoutRef.current = window.setTimeout(() => {
debugLog('[UMLVisualization] Executing instant auto-fit');
handleFitToScreen(0, true); // instant = true for initial load
}, 150);
}
}
// Drag functions
function dragstarted(event: any, d: any) {
if (simulation && !event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(this: SVGGElement, event: any, d: any) {
// Update position data immediately
d.fx = event.x;
d.fy = event.y;
d.x = event.x;
d.y = event.y;
// Update THIS node's transform directly and synchronously (no delay)
d3.select(this).attr('transform',
`translate(${d.x - (d.width || defaultNodeWidth) / 2}, ${d.y - (d.height || nodeHeaderHeight) / 2})`
);
// Always update links immediately during drag for perfect synchronization
// (Force simulation tick will also update, but we need immediate response)
updateLinkPositions();
}
function dragended(event: any, d: any) {
if (simulation && !event.active) simulation.alphaTarget(0);
// Keep fixed position after drag
d.fx = d.x;
d.fy = d.y;
// Update stored position for navigation
nodePositionsRef.current.set(d.id, { x: d.x, y: d.y });
}
// Click outside to deselect
svg.on('click', () => {
setSelectedNode(null);
setFocusedNodeId(null); // Clear edge focus on background click
});
// Cleanup
return () => {
if (simulation) simulation.stop();
if (autoFitTimeoutRef.current) {
clearTimeout(autoFitTimeoutRef.current);
autoFitTimeoutRef.current = null;
}
cleanup();
};
}, [diagram, width, height, layoutType, dagreDirection, dagreAlignment, dagreRanker, elkAlgorithm, showProvenanceLinks, showInheritanceLinks, showSlotRangeLinks, showDualClassLinks, showAttributesAsEdges, localCardinalityStyle]);
// Effect to handle edge fading when a node is focused via right-click
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
const linkGroups = svg.selectAll('.link-group');
const nodeGroups = svg.selectAll('.node');
if (focusedNodeId === null) {
// No focus - restore all edges and nodes to normal state
linkGroups.selectAll('line')
.transition()
.duration(200)
.attr('stroke-opacity', 0.7)
.attr('stroke-width', 2);
linkGroups.selectAll('.link-label')
.transition()
.duration(200)
.style('opacity', 0); // Keep labels hidden by default
linkGroups.selectAll('.link-label-bg')
.transition()
.duration(200)
.style('opacity', 0);
// Restore all nodes to normal state
nodeGroups.each(function(d: any) {
const nodeGroup = d3.select(this);
const nodeRect = nodeGroup.select('.node-rect');
const nodeType = d.type;
// Reset to original stroke color based on type
nodeRect
.transition()
.duration(200)
.attr('opacity', 1)
.attr('stroke', nodeType === 'enum' ? '#ffc107' : nodeType === 'slot' ? '#10b981' : '#0a3dfa')
.attr('stroke-width', 2)
.style('filter', null);
nodeGroup.selectAll('.node-header, .node-name, .node-type, .node-attribute, .node-method, line')
.transition()
.duration(200)
.attr('opacity', 1);
});
} else {
// Focus mode - fade out unconnected edges
linkGroups.each(function(d: any) {
const linkGroup = d3.select(this);
// Get source and target IDs (handle both string IDs and object references)
const sourceId = typeof d.source === 'string' ? d.source : d.source?.id;
const targetId = typeof d.target === 'string' ? d.target : d.target?.id;
const isConnected = sourceId === focusedNodeId || targetId === focusedNodeId;
if (isConnected) {
// Highlight connected edges
linkGroup.select('line')
.transition()
.duration(200)
.attr('stroke-opacity', 1)
.attr('stroke-width', 3);
// Show labels for connected edges
linkGroup.select('.link-label')
.transition()
.duration(200)
.style('opacity', 1);
linkGroup.select('.link-label-bg')
.transition()
.duration(200)
.style('opacity', 1);
} else {
// Fade out unconnected edges
linkGroup.select('line')
.transition()
.duration(200)
.attr('stroke-opacity', 0.1)
.attr('stroke-width', 1);
// Hide labels for faded edges
linkGroup.select('.link-label')
.transition()
.duration(200)
.style('opacity', 0);
linkGroup.select('.link-label-bg')
.transition()
.duration(200)
.style('opacity', 0);
}
});
// Also dim nodes that are not connected to the focused node
// First, find all connected node IDs
const connectedNodeIds = new Set<string>([focusedNodeId]);
linkGroups.each(function(d: any) {
const sourceId = typeof d.source === 'string' ? d.source : d.source?.id;
const targetId = typeof d.target === 'string' ? d.target : d.target?.id;
if (sourceId === focusedNodeId) {
connectedNodeIds.add(targetId);
} else if (targetId === focusedNodeId) {
connectedNodeIds.add(sourceId);
}
});
// Apply styling to nodes based on connection status
nodeGroups.each(function(d: any) {
const nodeGroup = d3.select(this);
const nodeRect = nodeGroup.select('.node-rect');
const isFocused = d.id === focusedNodeId;
const isConnected = connectedNodeIds.has(d.id);
if (isFocused) {
// Highlight the focused node with green border
nodeRect
.transition()
.duration(200)
.attr('opacity', 1)
.attr('stroke', '#22c55e')
.attr('stroke-width', 4)
.style('filter', 'drop-shadow(0 0 12px rgba(34, 197, 94, 0.5))');
nodeGroup.selectAll('.node-header, .node-name, .node-type, .node-attribute, .node-method, line')
.transition()
.duration(200)
.attr('opacity', 1);
} else if (isConnected) {
// Keep connected nodes visible
nodeRect
.transition()
.duration(200)
.attr('opacity', 1)
.attr('stroke', d.type === 'enum' ? '#ffc107' : d.type === 'slot' ? '#10b981' : '#0a3dfa')
.attr('stroke-width', 2)
.style('filter', null);
nodeGroup.selectAll('.node-header, .node-name, .node-type, .node-attribute, .node-method, line')
.transition()
.duration(200)
.attr('opacity', 1);
} else {
// Dim unconnected nodes
nodeRect
.transition()
.duration(200)
.attr('opacity', 0.3)
.style('filter', null);
nodeGroup.selectAll('.node-header, .node-name, .node-type, .node-attribute, .node-method, line')
.transition()
.duration(200)
.attr('opacity', 0.3);
}
});
}
}, [focusedNodeId]);
// Effect to update cardinality markers when style changes (UML vs Crow's Foot)
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
const linkLines = svg.selectAll('.link-group line');
// Helper to check if this is an ER-type relationship
const isERRelationship = (type: string) =>
type === 'one-to-one' || type === 'one-to-many' ||
type === 'many-to-one' || type === 'many-to-many';
// Update markers on all ER relationship lines
linkLines.each(function(d: any) {
if (!isERRelationship(d.type) || !d.cardinality) return;
const currentCardinality = d.isReversed ? (d.reverseCardinality || d.cardinality) : d.cardinality;
const parsed = parseCardinality(currentCardinality);
d3.select(this)
.attr('marker-end', `url(#${getCardinalityMarkerId(parsed.target, localCardinalityStyle)})`)
.attr('marker-start', `url(#${getCardinalityMarkerId(parsed.source, localCardinalityStyle)})`);
});
debugLog(`[UMLVisualization] Updated cardinality markers to ${localCardinalityStyle} style`);
}, [localCardinalityStyle]);
// Search handler - filter nodes based on query
const handleSearch = (query: string) => {
setSearchQuery(query);
setVisibleResultsCount(10); // Reset visible count on new search
if (query.trim() === '') {
setSearchResults([]);
setShowSearchResults(false);
return;
}
const lowerQuery = query.toLowerCase();
const matches = diagram.nodes.filter(node =>
node.name.toLowerCase().includes(lowerQuery) ||
node.attributes?.some(attr =>
attr.name.toLowerCase().includes(lowerQuery) ||
attr.type.toLowerCase().includes(lowerQuery)
)
);
setSearchResults(matches);
setShowSearchResults(matches.length > 0);
};
// Navigate to a specific node (zoom and center on it)
const navigateToNode = (node: UMLNode) => {
if (!svgRef.current || !zoomBehaviorRef.current) return;
const svg = d3.select(svgRef.current);
// Get the node's computed position from our position cache
const position = nodePositionsRef.current.get(node.id);
if (!position) {
console.warn(`[UMLVisualization] No position found for node ${node.id}`);
return;
}
// Get actual SVG dimensions (may differ from props if container resized)
const svgElement = svgRef.current;
const svgWidth = svgElement.clientWidth || width;
const svgHeight = svgElement.clientHeight || height;
// Use current zoom level or a modest default - don't zoom in too much
// This keeps surrounding nodes visible for context
const currentTransform = d3.zoomTransform(svg.node() as Element);
const scale = Math.max(currentTransform.k, 0.8); // Use current zoom or minimum 0.8x
// Center the node in the middle of the viewport
// Transform formula: screenX = nodeX * scale + translateX
// To center: svgWidth/2 = position.x * scale + translateX
// Therefore: translateX = svgWidth/2 - position.x * scale
const translateX = svgWidth / 2 - position.x * scale;
const translateY = svgHeight / 2 - position.y * scale;
// Animate to the node position
svg.transition()
.duration(500)
.call(
zoomBehaviorRef.current.transform as any,
d3.zoomIdentity.translate(translateX, translateY).scale(scale)
);
// Select and focus the node
setSelectedNode(node);
setFocusedNodeId(node.id);
// Clear search
setSearchQuery('');
setSearchResults([]);
setShowSearchResults(false);
};
return (
<div className="uml-visualization" ref={containerRef}>
<div className="uml-visualization__header">
{diagram.title && <h3 className="uml-visualization__title">{diagram.title}</h3>}
<div className="uml-visualization__controls">
{/* Search bar */}
<div className="uml-visualization__search">
<input
type="text"
className="uml-visualization__search-input"
placeholder="Search classes..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => searchResults.length > 0 && setShowSearchResults(true)}
onBlur={() => setTimeout(() => setShowSearchResults(false), 200)}
/>
{showSearchResults && searchResults.length > 0 && (
<div className="uml-visualization__search-results">
{searchResults.length > visibleResultsCount && (
<div className="uml-visualization__search-count">
Showing {visibleResultsCount} of {searchResults.length} results
</div>
)}
{searchResults.slice(0, visibleResultsCount).map(node => (
<button
key={node.id}
className="uml-visualization__search-result"
onClick={() => navigateToNode(node)}
>
<span className="uml-visualization__search-result-name">{node.name}</span>
<span className="uml-visualization__search-result-type">«{node.type}»</span>
</button>
))}
{searchResults.length > visibleResultsCount && visibleResultsCount < 50 && (
<button
className="uml-visualization__search-more-btn"
onMouseDown={(e) => {
e.preventDefault(); // Prevent blur from closing dropdown
setVisibleResultsCount(prev => Math.min(prev + 10, 50));
}}
>
Show more ({Math.min(10, searchResults.length - visibleResultsCount)} more)
</button>
)}
{visibleResultsCount >= 50 && searchResults.length > 50 && (
<div className="uml-visualization__search-more">
+{searchResults.length - 50} more results (max 50 shown)
</div>
)}
</div>
)}
</div>
{/* Link Type Filter dropdown */}
<div className="uml-visualization__legend-dropdown">
<button
className={`uml-visualization__toggle ${showLinkTypeMenu ? 'uml-visualization__toggle--active' : ''}`}
onClick={() => setShowLinkTypeMenu(!showLinkTypeMenu)}
title="Filter which link types to show"
>
<span className="uml-visualization__toggle-icon">🔗</span>
<span className="uml-visualization__toggle-label">Link Types</span>
</button>
{showLinkTypeMenu && (
<div className="uml-visualization__legend-panel">
<div className="uml-visualization__legend-title">
Visible Link Types
</div>
<div className="uml-visualization__legend-items">
<label className="uml-visualization__checkbox-item" title="Parent-child class relationships (is_a inheritance hierarchy)">
<input
type="checkbox"
checked={showInheritanceLinks}
onChange={() => setShowInheritanceLinks(!showInheritanceLinks)}
/>
<svg className="uml-visualization__link-icon" viewBox="0 0 24 14">
<defs>
<marker id="filter-inheritance" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<path d="M0,0 L8,5 L0,10 z" fill="white" stroke="#6366f1" strokeWidth="1"/>
</marker>
</defs>
<line x1="2" y1="7" x2="22" y2="7" stroke="#6366f1" strokeWidth="2" markerEnd="url(#filter-inheritance)"/>
</svg>
<span>Inheritance (is_a)</span>
</label>
<label className="uml-visualization__checkbox-item" title="Slots that reference other classes as their range type (aggregation/composition)">
<input
type="checkbox"
checked={showSlotRangeLinks}
onChange={() => setShowSlotRangeLinks(!showSlotRangeLinks)}
/>
<svg className="uml-visualization__link-icon" viewBox="0 0 24 14">
<defs>
<marker id="filter-aggregation" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<path d="M0,5 L4,0 L8,5 L4,10 z" fill="white" stroke="#6366f1" strokeWidth="1"/>
</marker>
</defs>
<line x1="2" y1="7" x2="22" y2="7" stroke="#6366f1" strokeWidth="2" markerEnd="url(#filter-aggregation)"/>
</svg>
<span>Slot Ranges</span>
</label>
<label className="uml-visualization__checkbox-item" title="Annotation-based links between CustodianType and CollectionType classes (linked_collection_type / linked_custodian_type)">
<input
type="checkbox"
checked={showDualClassLinks}
onChange={() => setShowDualClassLinks(!showDualClassLinks)}
/>
<svg className="uml-visualization__link-icon" viewBox="0 0 24 14">
<line x1="2" y1="7" x2="22" y2="7" stroke="#10b981" strokeWidth="2" strokeDasharray="4,2"/>
</svg>
<span>Dual-Class Pattern</span>
</label>
<label className="uml-visualization__checkbox-item" title="Links to provenance-tracking classes (Provenance, ChangeEvent, etc.)">
<input
type="checkbox"
checked={showProvenanceLinks}
onChange={() => setShowProvenanceLinks(!showProvenanceLinks)}
/>
<svg className="uml-visualization__link-icon" viewBox="0 0 24 14">
<line x1="2" y1="7" x2="22" y2="7" stroke="#f59e0b" strokeWidth="2" strokeDasharray="2,2"/>
</svg>
<span>Provenance Links</span>
</label>
</div>
{/* Display Options Section */}
<div className="uml-visualization__legend-title" style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid #e5e7eb' }}>
Display Options
</div>
<div className="uml-visualization__legend-items">
<label className="uml-visualization__checkbox-item" title="Show primitive slot attributes (string, integer, etc.) as edges to separate type nodes instead of inline in class boxes">
<input
type="checkbox"
checked={showAttributesAsEdges}
onChange={() => setShowAttributesAsEdges(!showAttributesAsEdges)}
/>
<svg className="uml-visualization__link-icon" viewBox="0 0 24 14">
<defs>
<marker id="filter-attribute-edge" markerWidth="12" markerHeight="12" refX="10" refY="5" orient="auto">
<path d="M0,0 L10,5 L0,10 L3,5 z" fill="#8b5cf6" stroke="#8b5cf6" strokeWidth="0.5"/>
</marker>
</defs>
<line x1="2" y1="7" x2="22" y2="7" stroke="#8b5cf6" strokeWidth="2" markerEnd="url(#filter-attribute-edge)"/>
</svg>
<span>Attributes as Edges</span>
</label>
</div>
</div>
)}
</div>
{/* Cardinality notation toggle */}
<button
className={`uml-visualization__toggle ${localCardinalityStyle === 'crowsfoot' ? 'uml-visualization__toggle--active' : ''}`}
onClick={() => {
const newStyle = localCardinalityStyle === 'uml' ? 'crowsfoot' : 'uml';
setLocalCardinalityStyle(newStyle);
onCardinalityStyleChange?.(newStyle);
}}
title={localCardinalityStyle === 'uml'
? 'Switch to Crow\'s Foot notation (database-style)'
: 'Switch to UML notation (circle/diamond symbols)'}
>
<span className="uml-visualization__toggle-icon">{localCardinalityStyle === 'uml' ? '◇' : '<'}</span>
<span className="uml-visualization__toggle-label">{localCardinalityStyle === 'uml' ? 'UML' : "Crow's Foot"}</span>
</button>
{/* Cardinality legend dropdown */}
<div className="uml-visualization__legend-dropdown">
<button
className={`uml-visualization__toggle ${showLegend ? 'uml-visualization__toggle--active' : ''}`}
onClick={() => setShowLegend(!showLegend)}
title="Show cardinality symbols legend"
>
<span className="uml-visualization__toggle-icon">?</span>
<span className="uml-visualization__toggle-label">Legend</span>
</button>
{showLegend && (
<div className="uml-visualization__legend-panel">
{/* Relationship Types Section */}
<div className="uml-visualization__legend-title">
Relationship Types
</div>
<div className="uml-visualization__legend-items">
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo
? hoveredEdgeInfo.relationshipType === 'inheritance'
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<svg className="uml-visualization__legend-arrow" viewBox="0 0 32 16">
<defs>
<marker id="legend-inheritance" markerWidth="14" markerHeight="14" refX="12" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 z" fill="white" stroke="#6366f1" strokeWidth="1.5"/>
</marker>
</defs>
<line x1="2" y1="8" x2="30" y2="8" stroke="#6366f1" strokeWidth="2" markerEnd="url(#legend-inheritance)"/>
</svg>
<span className="uml-visualization__legend-label">Inheritance (is_a / extends)</span>
</div>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo
? hoveredEdgeInfo.relationshipType === 'composition'
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<svg className="uml-visualization__legend-arrow" viewBox="0 0 32 16">
<defs>
<marker id="legend-composition" markerWidth="14" markerHeight="14" refX="12" refY="6" orient="auto">
<path d="M0,6 L6,0 L12,6 L6,12 z" fill="#6366f1" stroke="#6366f1" strokeWidth="1"/>
</marker>
</defs>
<line x1="2" y1="8" x2="30" y2="8" stroke="#6366f1" strokeWidth="2" markerEnd="url(#legend-composition)"/>
</svg>
<span className="uml-visualization__legend-label">Composition (owns, required)</span>
</div>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo
? hoveredEdgeInfo.relationshipType === 'aggregation'
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<svg className="uml-visualization__legend-arrow" viewBox="0 0 32 16">
<defs>
<marker id="legend-aggregation" markerWidth="14" markerHeight="14" refX="12" refY="6" orient="auto">
<path d="M0,6 L6,0 L12,6 L6,12 z" fill="white" stroke="#6366f1" strokeWidth="1.5"/>
</marker>
</defs>
<line x1="2" y1="8" x2="30" y2="8" stroke="#6366f1" strokeWidth="2" markerEnd="url(#legend-aggregation)"/>
</svg>
<span className="uml-visualization__legend-label">Aggregation (has, optional)</span>
</div>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo
? hoveredEdgeInfo.relationshipType === 'association'
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<svg className="uml-visualization__legend-arrow" viewBox="0 0 32 16">
<defs>
<marker id="legend-association" markerWidth="12" markerHeight="12" refX="10" refY="5" orient="auto">
<path d="M0,0 L10,5 L0,10 L3,5 z" fill="#6366f1" stroke="#6366f1" strokeWidth="0.5"/>
</marker>
</defs>
<line x1="2" y1="8" x2="30" y2="8" stroke="#6366f1" strokeWidth="2" markerEnd="url(#legend-association)"/>
</svg>
<span className="uml-visualization__legend-label">Association (references)</span>
</div>
</div>
{/* Cardinality Section */}
<div className="uml-visualization__legend-title" style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #e5e7eb' }}>
Cardinality ({localCardinalityStyle === 'uml' ? 'UML' : "Crow's Foot"})
</div>
<div className="uml-visualization__legend-items">
{localCardinalityStyle === 'uml' ? (
<>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo?.cardinality
? cardinalityMatchesType(hoveredEdgeInfo.cardinality, '1')
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--filled-circle"></span>
<span className="uml-visualization__legend-label">1 (required one)</span>
</div>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo?.cardinality
? cardinalityMatchesType(hoveredEdgeInfo.cardinality, '0..1')
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--open-circle"></span>
<span className="uml-visualization__legend-label">0..1 (optional one)</span>
</div>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo?.cardinality
? cardinalityMatchesType(hoveredEdgeInfo.cardinality, '1..*')
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--filled-diamond"></span>
<span className="uml-visualization__legend-label">1..* (required many)</span>
</div>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo?.cardinality
? cardinalityMatchesType(hoveredEdgeInfo.cardinality, '0..*')
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--open-diamond"></span>
<span className="uml-visualization__legend-label">0..* (optional many)</span>
</div>
</>
) : (
<>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo?.cardinality
? cardinalityMatchesType(hoveredEdgeInfo.cardinality, '1')
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--bar"></span>
<span className="uml-visualization__legend-label">1 (exactly one)</span>
</div>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo?.cardinality
? cardinalityMatchesType(hoveredEdgeInfo.cardinality, '0..1')
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--circle-bar"></span>
<span className="uml-visualization__legend-label">0..1 (zero or one)</span>
</div>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo?.cardinality
? cardinalityMatchesType(hoveredEdgeInfo.cardinality, '1..*')
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--crowsfoot-bar">&lt;</span>
<span className="uml-visualization__legend-label">1..* (one or more)</span>
</div>
<div className={`uml-visualization__legend-item${
hoveredEdgeInfo?.cardinality
? cardinalityMatchesType(hoveredEdgeInfo.cardinality, '0..*')
? ' uml-visualization__legend-item--highlighted'
: ' uml-visualization__legend-item--dimmed'
: ''
}`}>
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--circle-crowsfoot">&lt;</span>
<span className="uml-visualization__legend-label">0..* (zero or more)</span>
</div>
</>
)}
</div>
</div>
)}
</div>
<span className="uml-visualization__zoom">Zoom: {(zoom * 100).toFixed(0)}%</span>
<button
className="uml-visualization__button"
onClick={() => {
const svg = d3.select(svgRef.current);
svg.transition().duration(750).call(
d3.zoom<SVGSVGElement, unknown>().transform as any,
d3.zoomIdentity
);
}}
>
Reset View
</button>
</div>
</div>
<div className="uml-visualization__canvas">
<svg ref={svgRef} />
</div>
{/* Show semantic details panel for classes and enums, but NOT for slots (predicates) */}
{selectedNode && selectedNode.type !== 'slot' && (
<SemanticDetailsPanel
className={selectedNode.name}
onClose={() => setSelectedNode(null)}
/>
)}
{/* Floating edge tooltip - clamped to stay within container bounds */}
{edgeTooltip && (() => {
// Tooltip dimensions (approximate - matches CSS)
const tooltipWidth = 200; // max-width from CSS + some padding
const tooltipHeight = 50; // approximate height for single line + hint
const padding = 8; // padding from edges
// Calculate position, clamping to container bounds
let left = edgeTooltip.x + 12;
let top = edgeTooltip.y - 8;
// Clamp right edge
if (left + tooltipWidth > edgeTooltip.containerWidth - padding) {
left = edgeTooltip.x - tooltipWidth - 12; // Show on left side of cursor
}
// Clamp left edge
if (left < padding) {
left = padding;
}
// Clamp bottom edge
if (top + tooltipHeight > edgeTooltip.containerHeight - padding) {
top = edgeTooltip.y - tooltipHeight - 8; // Show above cursor
}
// Clamp top edge
if (top < padding) {
top = padding;
}
return (
<div
className="uml-visualization__edge-tooltip"
style={{ left, top }}
>
<span className="uml-visualization__edge-tooltip-label">{edgeTooltip.label}</span>
{edgeTooltip.bidirectional && (
<span className="uml-visualization__edge-tooltip-hint">Click to reverse</span>
)}
</div>
);
})()}
</div>
);
};
// Export memoized version of the component to prevent unnecessary re-renders
// Uses custom comparison function to check if diagram content actually changed
export const UMLVisualization = memo(UMLVisualizationInner, arePropsEqual);