import React, { useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import dagre from 'dagre'; import './UMLVisualization.css'; 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; } 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 } 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'; interface UMLVisualizationProps { diagram: UMLDiagram; width?: number; height?: number; diagramType?: DiagramType; layoutType?: 'force' | 'dagre'; dagreDirection?: DagreDirection; dagreAlignment?: DagreAlignment; dagreRanker?: DagreRanker; } export const UMLVisualization: React.FC = ({ diagram, width = 1200, height = 800, // diagramType = 'mermaid-class', // Unused for now layoutType = 'force', dagreDirection = 'TB', dagreAlignment = undefined, dagreRanker = 'network-simplex' }) => { const svgRef = useRef(null); const containerRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [zoom, setZoom] = useState(1); const zoomTransformRef = useRef(d3.zoomIdentity); const previousDiagramRef = useRef(null); // Track diagram changes for auto-fit useEffect(() => { if (!svgRef.current || !diagram) return; // 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; 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(); // Create SVG with zoom behavior const svg = d3.select(svgRef.current) .attr('width', width) .attr('height', height); const g = svg.append('g'); // Add zoom behavior const zoomBehavior = d3.zoom() .scaleExtent([0.1, 4]) .on('zoom', (event) => { g.attr('transform', event.transform); setZoom(event.transform.k); }); svg.call(zoomBehavior); // 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 = () => { // Get bounding box of all nodes const bounds = g.node()?.getBBox(); if (!bounds) return; const fullWidth = width; const fullHeight = height; const midX = bounds.x + bounds.width / 2; const midY = bounds.y + bounds.height / 2; const scale = 0.9 / Math.max(bounds.width / fullWidth, bounds.height / fullHeight); 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 = '\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 workingLinks = 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 })); 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 or Dagre grid let simulation: d3.Simulation | null = null; if (layoutType === 'dagre') { // Dagre grid layout (tight, hierarchical like Mermaid) const g = 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) g.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 }); g.setDefaultEdgeLabel(() => ({})); // Add nodes to dagre graph workingNodes.forEach(node => { g.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 => { g.setEdge(link.source as string, link.target as string); }); // Run dagre layout dagre.layout(g); // Apply computed positions to nodes workingNodes.forEach(node => { const dagreNode = g.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; } }); // No simulation needed - positions are fixed 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); } }); // 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 links first (edges between nodes) const links = g.append('g') .attr('class', 'links') .selectAll('g') .data(workingLinks) .join('g') .attr('class', 'link-group'); links.append('line') .attr('class', (d) => `link link-${d.type}${d.bidirectional ? ' link-bidirectional' : ''}`) .attr('stroke', (d) => d.bidirectional ? '#6366f1' : '#0a3dfa') // Indigo for bidirectional .attr('stroke-width', 2) .attr('stroke-opacity', 0.7) .attr('stroke-dasharray', (d) => d.bidirectional ? '5,3' : 'none') // Dashed for bidirectional .attr('marker-end', (d) => { const arrowType = d.type === 'one-to-one' || d.type === 'one-to-many' || d.type === 'many-to-one' || d.type === 'many-to-many' ? 'association' : d.type; return `url(#arrow-${arrowType})`; }) .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 = d.type === 'one-to-one' || d.type === 'one-to-many' || d.type === 'many-to-one' || d.type === 'many-to-many' ? 'association' : d.type; d3.select(this) .attr('marker-end', `url(#arrow-${arrowType}-highlight)`) .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', `url(#arrow-${arrowType})`); // 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 = d.type === 'one-to-one' || d.type === 'one-to-many' || d.type === 'many-to-one' || d.type === 'many-to-many' ? 'association' : d.type; d3.select(this) .transition() .duration(200) .attr('stroke-opacity', 1) .attr('stroke-width', 3) .attr('marker-end', `url(#arrow-${arrowType}-highlight)`); // Show label more prominently const linkIndex = workingLinks.indexOf(d); linkLabels.filter((_: any, i: number) => i === linkIndex) .transition() .duration(200) .style('opacity', 1) .attr('font-weight', 'bold'); // Show tooltip for bidirectional edges if (d.bidirectional && this.parentNode) { d3.select(this.parentNode as Element) .append('title') .text('Click to reverse direction'); } }) .on('mouseleave', function(_event, d: any) { // Reset edge appearance const arrowType = d.type === 'one-to-one' || d.type === 'one-to-many' || d.type === 'many-to-one' || d.type === 'many-to-many' ? 'association' : d.type; d3.select(this) .transition() .duration(200) .attr('stroke-opacity', 0.7) .attr('stroke-width', 2) .attr('marker-end', `url(#arrow-${arrowType})`); // Reset label - hide it again const linkIndex = workingLinks.indexOf(d); linkLabels.filter((_: any, i: number) => i === linkIndex) .transition() .duration(200) .style('opacity', 0) // Hide label again .attr('font-weight', 'normal'); // Remove tooltip if (this.parentNode) { d3.select(this.parentNode as Element).select('title').remove(); } }); // Add link labels with background for readability // First add background rects (will be positioned in updateLinkPositions) const linkLabelBackgrounds = 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}`) .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended) as any); // 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) .on('click', (event, d) => { event.stopPropagation(); setSelectedNode(d); }); // 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' ? '#ffc107' : '#0a3dfa') .attr('opacity', 0.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', '#172a59') .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', '#666') .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(); 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})` ); }; // Update positions on tick (force simulation) or immediately (dagre) 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 setTimeout(() => handleFitToScreen(), 100); }); } } else { // Dagre layout - positions are already computed, update immediately updateNodePositions(); updateLinkPositions(); // Auto-fit to screen for new diagrams (delay to ensure DOM is ready) if (isNewDiagram) { setTimeout(() => handleFitToScreen(), 100); } } // 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; } // Click outside to deselect svg.on('click', () => setSelectedNode(null)); // Cleanup return () => { if (simulation) simulation.stop(); cleanup(); }; }, [diagram, width, height, layoutType, dagreDirection, dagreAlignment, dagreRanker]); return (
{diagram.title &&

{diagram.title}

}
Zoom: {(zoom * 100).toFixed(0)}%
{selectedNode && (

{selectedNode.name}

Type: {selectedNode.type}

{selectedNode.attributes && selectedNode.attributes.length > 0 && ( <>

Attributes ({selectedNode.attributes.length}):

    {selectedNode.attributes.map((attr, i) => (
  • {attr.name}: {attr.type}
  • ))}
)} {selectedNode.methods && selectedNode.methods.length > 0 && ( <>

Methods ({selectedNode.methods.length}):

    {selectedNode.methods.map((method, i) => (
  • {method.name}(){method.returnType ? `: ${method.returnType}` : ''}
  • ))}
)}
)}
); };