- 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.
1067 lines
39 KiB
TypeScript
1067 lines
39 KiB
TypeScript
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>
|
||
);
|
||
};
|