glam/frontend/src/components/uml/UMLVisualization.tsx
kempersc 5ef8ccac51 Add script to enrich NDE Register NL entries with Wikidata data
- Implemented a Python script that fetches and enriches entries from the NDE Register using data from Wikidata.
- Utilized the Wikibase REST API and SPARQL endpoints for data retrieval.
- Added logging for tracking progress and errors during the enrichment process.
- Configured rate limiting based on authentication status for API requests.
- Created a structured output in YAML format, including detailed enrichment data.
- Generated a log file summarizing the enrichment process and results.
2025-11-27 13:30:00 +01:00

1067 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<UMLVisualizationProps> = ({
diagram,
width = 1200,
height = 800,
// diagramType = 'mermaid-class', // Unused for now
layoutType = 'force',
dagreDirection = 'TB',
dagreAlignment = undefined,
dagreRanker = 'network-simplex'
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [selectedNode, setSelectedNode] = useState<UMLNode | null>(null);
const [zoom, setZoom] = useState(1);
const zoomTransformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity);
const previousDiagramRef = useRef<string | null>(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<SVGSVGElement, unknown>()
.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 = '<?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 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<any, undefined> | 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<any, any>()
.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<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})`
);
};
// 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 (
<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">
<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 && (
<div className="uml-visualization__details">
<div className="uml-visualization__details-header">
<h4>{selectedNode.name}</h4>
<button
className="uml-visualization__close"
onClick={() => setSelectedNode(null)}
>
×
</button>
</div>
<div className="uml-visualization__details-content">
<p><strong>Type:</strong> {selectedNode.type}</p>
{selectedNode.attributes && selectedNode.attributes.length > 0 && (
<>
<p><strong>Attributes ({selectedNode.attributes.length}):</strong></p>
<ul>
{selectedNode.attributes.map((attr, i) => (
<li key={i}>{attr.name}: {attr.type}</li>
))}
</ul>
</>
)}
{selectedNode.methods && selectedNode.methods.length > 0 && (
<>
<p><strong>Methods ({selectedNode.methods.length}):</strong></p>
<ul>
{selectedNode.methods.map((method, i) => (
<li key={i}>
{method.name}(){method.returnType ? `: ${method.returnType}` : ''}
</li>
))}
</ul>
</>
)}
</div>
</div>
)}
</div>
);
};