2345 lines
91 KiB
TypeScript
2345 lines
91 KiB
TypeScript
import React, { useEffect, useRef, useState } 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';
|
|
|
|
export type DiagramType = 'mermaid-class' | 'mermaid-er' | 'plantuml' | 'graphviz';
|
|
|
|
export interface UMLNode {
|
|
id: string;
|
|
name: string;
|
|
type: 'class' | 'enum' | 'entity';
|
|
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)
|
|
}
|
|
|
|
// 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}`;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
export const UMLVisualization: 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 [showProvenanceLinks, setShowProvenanceLinks] = useState(false); // Provenance links hidden by default
|
|
const [showLegend, setShowLegend] = useState(false); // Cardinality legend dropdown
|
|
const [edgeTooltip, setEdgeTooltip] = useState<{ label: string; x: number; y: number; bidirectional: boolean } | null>(null);
|
|
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(() => {
|
|
console.log('[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 layout change (preserve zoom)
|
|
const diagramId = diagram.title || JSON.stringify(diagram.nodes.map(n => n.id).sort());
|
|
const isNewDiagram = previousDiagramRef.current !== diagramId;
|
|
console.log('[UMLVisualization] Diagram check:', {
|
|
diagramId,
|
|
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;
|
|
|
|
console.log(`[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) => {
|
|
// Get bounding box of all nodes
|
|
const bounds = g.node()?.getBBox();
|
|
|
|
// Debug: Log bounds on every attempt
|
|
console.log(`[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);
|
|
console.log(`[UMLVisualization] Bounds not ready, retrying in ${delay}ms (attempt ${retryCount + 1}/5)`);
|
|
setTimeout(() => handleFitToScreen(retryCount + 1), 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) {
|
|
console.log('[UMLVisualization] DEBUG - g element children:', gNode.children.length);
|
|
console.log('[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;
|
|
|
|
console.log(`[UMLVisualization] Fit-to-screen using container dimensions: ${fullWidth}x${fullHeight}`);
|
|
|
|
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];
|
|
|
|
svg.transition().duration(500).call(
|
|
zoomBehavior.transform as any,
|
|
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)
|
|
);
|
|
};
|
|
|
|
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 using a temporary SVG text element
|
|
// 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 => {
|
|
const tempText = g.append('text')
|
|
.attr('visibility', 'hidden')
|
|
.attr('font-size', fontSize)
|
|
.attr('font-weight', fontWeight)
|
|
.attr('font-family', fontFamily)
|
|
.text(text);
|
|
|
|
const bbox = (tempText.node() as SVGTextElement)?.getBBox();
|
|
const width = bbox?.width || 0;
|
|
tempText.remove();
|
|
|
|
return width;
|
|
};
|
|
|
|
// Calculate dynamic width for each node based on its content
|
|
const calculateNodeWidth = (node: UMLNode): 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!)
|
|
if (node.attributes) {
|
|
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 => ({
|
|
...link,
|
|
// Always ensure source/target are strings (force simulation may have converted them to objects)
|
|
source: typeof link.source === 'string' ? link.source : (link.source as any).id,
|
|
target: typeof link.target === 'string' ? link.target : (link.target as any).id
|
|
}));
|
|
|
|
// Filter out provenance links if toggle is off
|
|
const workingLinks = showProvenanceLinks
|
|
? allLinks
|
|
: allLinks.filter(link => !link.isProvenance);
|
|
|
|
// Log provenance link filtering
|
|
const provenanceCount = allLinks.filter(l => l.isProvenance).length;
|
|
if (provenanceCount > 0) {
|
|
console.log(`[UMLVisualization] Provenance links: ${provenanceCount} (${showProvenanceLinks ? 'shown' : 'hidden'})`);
|
|
}
|
|
|
|
workingNodes.forEach(node => {
|
|
const attributeCount = node.attributes?.length || 0;
|
|
const methodCount = node.methods?.length || 0;
|
|
|
|
// Calculate dynamic width based on content
|
|
node.width = calculateNodeWidth(node);
|
|
node.height = nodeHeaderHeight +
|
|
(attributeCount > 0 ? attributeCount * attributeHeight + nodePadding : 0) +
|
|
(methodCount > 0 ? methodCount * methodHeight + nodePadding : 0);
|
|
});
|
|
|
|
// 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
|
|
console.log('[UMLVisualization v2] ===== DAGRE LAYOUT COMPLETE =====');
|
|
const sampleNodes = workingNodes.slice(0, 3);
|
|
console.log('[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 })));
|
|
console.log('[UMLVisualization v2] Viewport dimensions:', { width, height });
|
|
console.log('[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 });
|
|
}
|
|
});
|
|
|
|
console.log('[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 });
|
|
});
|
|
|
|
console.log('[UMLVisualization] ===== CIRCULAR LAYOUT COMPLETE =====');
|
|
console.log(`[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 });
|
|
});
|
|
});
|
|
|
|
console.log('[UMLVisualization] ===== RADIAL LAYOUT COMPLETE =====');
|
|
console.log(`[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);
|
|
});
|
|
|
|
console.log('[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
|
|
}));
|
|
|
|
console.log('[UMLVisualization] ===== CLUSTERED LAYOUT COMPLETE =====');
|
|
console.log(`[UMLVisualization] Clustered: ${clusters.length} clusters positioned`);
|
|
|
|
simulation = null;
|
|
|
|
} else {
|
|
// Force simulation for layout (original scattered physics-based layout)
|
|
simulation = d3.forceSimulation(workingNodes as any)
|
|
.force('link', d3.forceLink(workingLinks)
|
|
.id((d: any) => d.id)
|
|
.distance(250)
|
|
.strength(0.5))
|
|
.force('charge', d3.forceManyBody().strength(-1000))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collision', d3.forceCollide().radius(150));
|
|
}
|
|
|
|
// Create arrow markers for different relationship types with highlighted versions
|
|
const defs = g.append('defs');
|
|
|
|
// Define arrow types with both normal and highlighted versions
|
|
const arrowTypes = [
|
|
{ id: 'inheritance', path: 'M0,0 L0,6 L9,3 z', fill: 'white', stroke: '#172a59', strokeWidth: 1.5, size: [10, 10, 9, 3] },
|
|
{ id: 'composition', path: 'M0,3 L6,0 L12,3 L6,6 z', fill: '#172a59', stroke: '', strokeWidth: 0, size: [12, 12, 11, 3] },
|
|
{ id: 'aggregation', path: 'M0,3 L6,0 L12,3 L6,6 z', fill: 'white', stroke: '#172a59', strokeWidth: 1.5, size: [12, 12, 11, 3] },
|
|
{ id: 'association', path: 'M0,0 L0,6 L9,3 z', fill: '#172a59', stroke: '', strokeWidth: 0, size: [10, 10, 9, 3] },
|
|
];
|
|
|
|
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
|
|
// UML Style markers
|
|
const cardinalityMarkerSize = 8;
|
|
|
|
// 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', 3)
|
|
.attr('fill', '#3b82f6')
|
|
.attr('stroke', '#1d4ed8')
|
|
.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', 3)
|
|
.attr('fill', 'white')
|
|
.attr('stroke', '#3b82f6')
|
|
.attr('stroke-width', 1.5);
|
|
|
|
// Diamond (many required) - UML style
|
|
defs.append('marker')
|
|
.attr('id', 'card-many-required')
|
|
.attr('markerWidth', 10)
|
|
.attr('markerHeight', cardinalityMarkerSize)
|
|
.attr('refX', 5)
|
|
.attr('refY', cardinalityMarkerSize / 2)
|
|
.attr('orient', 'auto')
|
|
.append('path')
|
|
.attr('d', `M0,${cardinalityMarkerSize/2} L5,0 L10,${cardinalityMarkerSize/2} L5,${cardinalityMarkerSize} Z`)
|
|
.attr('fill', '#3b82f6')
|
|
.attr('stroke', '#1d4ed8')
|
|
.attr('stroke-width', 1);
|
|
|
|
// Open diamond (many optional) - UML style
|
|
defs.append('marker')
|
|
.attr('id', 'card-many-optional')
|
|
.attr('markerWidth', 10)
|
|
.attr('markerHeight', cardinalityMarkerSize)
|
|
.attr('refX', 5)
|
|
.attr('refY', cardinalityMarkerSize / 2)
|
|
.attr('orient', 'auto')
|
|
.append('path')
|
|
.attr('d', `M0,${cardinalityMarkerSize/2} L5,0 L10,${cardinalityMarkerSize/2} L5,${cardinalityMarkerSize} Z`)
|
|
.attr('fill', 'white')
|
|
.attr('stroke', '#3b82f6')
|
|
.attr('stroke-width', 1.5);
|
|
|
|
// Crow's Foot markers
|
|
// Bar (exactly one)
|
|
defs.append('marker')
|
|
.attr('id', 'crowsfoot-one')
|
|
.attr('markerWidth', 12)
|
|
.attr('markerHeight', 12)
|
|
.attr('refX', 6)
|
|
.attr('refY', 6)
|
|
.attr('orient', 'auto')
|
|
.append('path')
|
|
.attr('d', 'M6,0 L6,12')
|
|
.attr('stroke', '#3b82f6')
|
|
.attr('stroke-width', 2)
|
|
.attr('fill', 'none');
|
|
|
|
// Circle + Bar (zero or one)
|
|
const zeroOneMarker = defs.append('marker')
|
|
.attr('id', 'crowsfoot-zero-one')
|
|
.attr('markerWidth', 16)
|
|
.attr('markerHeight', 12)
|
|
.attr('refX', 8)
|
|
.attr('refY', 6)
|
|
.attr('orient', 'auto');
|
|
zeroOneMarker.append('circle')
|
|
.attr('cx', 4)
|
|
.attr('cy', 6)
|
|
.attr('r', 3)
|
|
.attr('fill', 'white')
|
|
.attr('stroke', '#3b82f6')
|
|
.attr('stroke-width', 1.5);
|
|
zeroOneMarker.append('path')
|
|
.attr('d', 'M10,0 L10,12')
|
|
.attr('stroke', '#3b82f6')
|
|
.attr('stroke-width', 2)
|
|
.attr('fill', 'none');
|
|
|
|
// Crow's foot (one or more)
|
|
defs.append('marker')
|
|
.attr('id', 'crowsfoot-one-many')
|
|
.attr('markerWidth', 14)
|
|
.attr('markerHeight', 14)
|
|
.attr('refX', 7)
|
|
.attr('refY', 7)
|
|
.attr('orient', 'auto')
|
|
.append('path')
|
|
.attr('d', 'M0,0 L10,7 L0,14 M10,0 L10,14')
|
|
.attr('stroke', '#3b82f6')
|
|
.attr('stroke-width', 1.5)
|
|
.attr('fill', 'none');
|
|
|
|
// Circle + Crow's foot (zero or more)
|
|
const zeroManyMarker = defs.append('marker')
|
|
.attr('id', 'crowsfoot-zero-many')
|
|
.attr('markerWidth', 18)
|
|
.attr('markerHeight', 14)
|
|
.attr('refX', 9)
|
|
.attr('refY', 7)
|
|
.attr('orient', 'auto');
|
|
zeroManyMarker.append('circle')
|
|
.attr('cx', 4)
|
|
.attr('cy', 7)
|
|
.attr('r', 3)
|
|
.attr('fill', 'white')
|
|
.attr('stroke', '#3b82f6')
|
|
.attr('stroke-width', 1.5);
|
|
zeroManyMarker.append('path')
|
|
.attr('d', 'M8,0 L18,7 L8,14')
|
|
.attr('stroke', '#3b82f6')
|
|
.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;
|
|
console.log(`[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;
|
|
console.log(`[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';
|
|
|
|
links.append('line')
|
|
.attr('class', (d) => {
|
|
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';
|
|
return classes;
|
|
})
|
|
.attr('stroke', (d) => {
|
|
if (d.isProvenance) return '#9ca3af'; // Gray for provenance
|
|
if (d.bidirectional) return '#6366f1'; // Indigo for bidirectional
|
|
return '#0a3dfa'; // Blue for normal
|
|
})
|
|
.attr('stroke-width', 2)
|
|
.attr('stroke-opacity', (d) => d.isProvenance ? 0.6 : 0.7)
|
|
.attr('stroke-dasharray', (d) => {
|
|
if (d.isProvenance) return '2,2'; // Dotted for provenance
|
|
if (d.bidirectional) return '5,3'; // Dashed for bidirectional
|
|
return 'none';
|
|
})
|
|
.attr('marker-end', (d) => {
|
|
// 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})`;
|
|
})
|
|
.attr('marker-start', (d) => {
|
|
// 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;
|
|
})
|
|
.style('cursor', (d) => d.bidirectional ? 'pointer' : 'default')
|
|
.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;
|
|
|
|
// 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
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.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
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.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);
|
|
});
|
|
|
|
// 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();
|
|
console.log('[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);
|
|
|
|
console.log('[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' : '#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' : '#e0e7ff') // Light amber for enum, 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' : '#312e81') // Dark amber for enum, 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' : '#4338ca') // Matching darker tones
|
|
.attr('font-size', '10px')
|
|
.attr('font-style', 'italic')
|
|
.text((d) => `«${d.type}»`);
|
|
|
|
// Draw attributes section
|
|
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;
|
|
|
|
// Calculate border intersections once
|
|
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
|
|
const linkGroup = d3.select(this);
|
|
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
|
|
console.log('[UMLVisualization] Force simulation ended, scheduling auto-fit');
|
|
autoFitTimeoutRef.current = window.setTimeout(() => {
|
|
console.log('[UMLVisualization] Executing auto-fit after force simulation');
|
|
handleFitToScreen();
|
|
}, 150);
|
|
});
|
|
}
|
|
} else if (layoutPromise) {
|
|
// ELK layout is async - wait for promise then update positions
|
|
layoutPromise.then(() => {
|
|
updateNodePositions();
|
|
updateLinkPositions();
|
|
|
|
console.log('[UMLVisualization] After ELK render - updating positions');
|
|
|
|
// Auto-fit to screen for new diagrams
|
|
if (isNewDiagram) {
|
|
console.log('[UMLVisualization] Scheduling auto-fit for new ELK diagram');
|
|
autoFitTimeoutRef.current = window.setTimeout(() => {
|
|
console.log('[UMLVisualization] Executing auto-fit after ELK');
|
|
handleFitToScreen();
|
|
}, 150);
|
|
}
|
|
});
|
|
} else {
|
|
// Dagre/Circular/Radial layout - positions are already computed, update immediately
|
|
updateNodePositions();
|
|
updateLinkPositions();
|
|
|
|
// Debug: Check what's actually rendered
|
|
const gNode = g.node();
|
|
console.log('[UMLVisualization] After layout render - g element children:', gNode?.children.length);
|
|
console.log('[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');
|
|
console.log(`[UMLVisualization] Node ${d.id} transform:`, transform, 'data x/y:', d.x, d.y);
|
|
});
|
|
|
|
// Debug: Check bounding box immediately
|
|
const bbox = gNode?.getBBox();
|
|
console.log('[UMLVisualization] Immediate getBBox:', bbox);
|
|
|
|
// Auto-fit to screen for new diagrams (delay to ensure DOM is ready)
|
|
if (isNewDiagram) {
|
|
console.log('[UMLVisualization] Scheduling auto-fit for new diagram');
|
|
autoFitTimeoutRef.current = window.setTimeout(() => {
|
|
console.log('[UMLVisualization] Executing auto-fit');
|
|
handleFitToScreen();
|
|
}, 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, 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' : '#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' : '#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)})`);
|
|
});
|
|
|
|
console.log(`[UMLVisualization] Updated cardinality markers to ${localCardinalityStyle} style`);
|
|
}, [localCardinalityStyle]);
|
|
|
|
// Search handler - filter nodes based on query
|
|
const handleSearch = (query: string) => {
|
|
setSearchQuery(query);
|
|
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.slice(0, 10).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 > 10 && (
|
|
<div className="uml-visualization__search-more">
|
|
+{searchResults.length - 10} more results
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Provenance links toggle */}
|
|
<button
|
|
className={`uml-visualization__toggle ${showProvenanceLinks ? 'uml-visualization__toggle--active' : ''}`}
|
|
onClick={() => setShowProvenanceLinks(!showProvenanceLinks)}
|
|
title={showProvenanceLinks ? 'Hide provenance links (was_derived_from, etc.)' : 'Show provenance links (was_derived_from, etc.)'}
|
|
>
|
|
<span className="uml-visualization__toggle-icon">⛓</span>
|
|
<span className="uml-visualization__toggle-label">Provenance</span>
|
|
</button>
|
|
{/* 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">
|
|
<div className="uml-visualization__legend-title">
|
|
Cardinality ({localCardinalityStyle === 'uml' ? 'UML' : "Crow's Foot"})
|
|
</div>
|
|
<div className="uml-visualization__legend-items">
|
|
{localCardinalityStyle === 'uml' ? (
|
|
<>
|
|
<div className="uml-visualization__legend-item">
|
|
<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">
|
|
<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">
|
|
<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">
|
|
<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">
|
|
<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">
|
|
<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">
|
|
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--crowsfoot-bar"><┤</span>
|
|
<span className="uml-visualization__legend-label">1..* (one or more)</span>
|
|
</div>
|
|
<div className="uml-visualization__legend-item">
|
|
<span className="uml-visualization__legend-symbol uml-visualization__legend-symbol--circle-crowsfoot">○<</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>
|
|
|
|
{selectedNode && (
|
|
<SemanticDetailsPanel
|
|
className={selectedNode.name}
|
|
onClose={() => setSelectedNode(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Floating edge tooltip - follows mouse position */}
|
|
{edgeTooltip && (
|
|
<div
|
|
className="uml-visualization__edge-tooltip"
|
|
style={{
|
|
left: edgeTooltip.x + 12,
|
|
top: edgeTooltip.y - 8
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
};
|