function renderGraph(containerSelector, graph, options = {}) { const container = document.querySelector(containerSelector); container.innerHTML = ''; const width = container.clientWidth || 800; // Use viewport height as baseline; allow large canvases const height = Math.max(window.innerHeight - 100, container.clientHeight || 500, 600); const paddingX = 60; const paddingY = 30; const svg = d3.select(container) .append('svg') .attr('viewBox', [0, 0, width, height]) .attr('width', '100%') .attr('height', height); const g = svg.append('g'); const zoom = d3.zoom().on('zoom', (event) => { g.attr('transform', event.transform); }); svg.call(zoom); const links = graph.links.map(d => Object.assign({}, d)); const nodes = graph.nodes.map(d => Object.assign({}, d)); const endpoints = Array.isArray(graph.endpoints) ? graph.endpoints : []; // Visual constants const nodeRadius = 16; const labelOffset = 12; // below the circle // Compute left-to-right depth strictly from path segment indices const { depths, maxDepth } = computeDepthsFromEndpoints(nodes, endpoints); const depthScale = d3.scaleLinear() .domain([0, Math.max(1, maxDepth)]) .range([paddingX, Math.max(paddingX + 1, width - paddingX)]); // Seed initial positions near their depth column with limited vertical jitter for (const n of nodes) { const d = depths.get(n.id) ?? 0; n.x = depthScale(d) + (Math.random() - 0.5) * 30; n.y = height / 2 + (Math.random() - 0.5) * (height * 0.3); } const link = g.append('g') .attr('stroke-width', 1.5) .selectAll('line') .data(links) .enter().append('line') .attr('class', 'link'); const node = g.append('g') .selectAll('g') .data(nodes) .enter().append('g') .attr('class', 'node'); node.append('circle') .attr('r', nodeRadius) .call(drag(simulation())); node.append('text') .attr('x', 0) .attr('y', nodeRadius + labelOffset) .attr('text-anchor', 'middle') .text(d => d.id); // Node click -> delegate to callback if provided if (typeof options.onNodeClick === 'function') { node.style('cursor', 'pointer') .on('click', (event, d) => { options.onNodeClick(d.id, d); }); } // Node hover -> highlight connected links node .on('mouseenter', (event, d) => { link.classed('highlighted', l => { const sourceId = typeof l.source === 'object' ? l.source.id : l.source; const targetId = typeof l.target === 'object' ? l.target.id : l.target; return sourceId === d.id || targetId === d.id; }); }) .on('mouseleave', () => { link.classed('highlighted', false); }); // Build adjacency map for each node (which links it's part of) const nodeLinks = new Map(); for (const n of nodes) { nodeLinks.set(n.id, new Set()); } for (const l of links) { const sourceId = typeof l.source === 'object' ? l.source.id : l.source; const targetId = typeof l.target === 'object' ? l.target.id : l.target; nodeLinks.get(sourceId)?.add(l); nodeLinks.get(targetId)?.add(l); } const sim = simulation(); sim.nodes(nodes).on('tick', ticked); sim.force('link').links(links); function distanceToLineSegment(px, py, x1, y1, x2, y2) { const dx = x2 - x1; const dy = y2 - y1; const len2 = dx * dx + dy * dy; if (len2 === 0) { const dx2 = px - x1; const dy2 = py - y1; return Math.sqrt(dx2 * dx2 + dy2 * dy2); } const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2)); const projX = x1 + t * dx; const projY = y1 + t * dy; const dx2 = px - projX; const dy2 = py - projY; return Math.sqrt(dx2 * dx2 + dy2 * dy2); } function ticked() { // Clamp nodes vertically to avoid drifting too far down/up const minY = paddingY + nodeRadius; const maxY = height - paddingY - nodeRadius - labelOffset - 2; // Push nodes away from links they don't belong to const minDistFromLink = nodeRadius + 8; // minimum distance from unrelated links for (const n of nodes) { const connectedLinks = nodeLinks.get(n.id) || new Set(); for (const l of links) { if (connectedLinks.has(l)) continue; // skip links this node belongs to const sourceId = typeof l.source === 'object' ? l.source.id : l.source; const targetId = typeof l.target === 'object' ? l.target.id : l.target; const sx = typeof l.source === 'object' ? l.source.x : (nodes.find(nn => nn.id === sourceId)?.x || 0); const sy = typeof l.source === 'object' ? l.source.y : (nodes.find(nn => nn.id === sourceId)?.y || 0); const tx = typeof l.target === 'object' ? l.target.x : (nodes.find(nn => nn.id === targetId)?.x || 0); const ty = typeof l.target === 'object' ? l.target.y : (nodes.find(nn => nn.id === targetId)?.y || 0); const dist = distanceToLineSegment(n.x, n.y, sx, sy, tx, ty); if (dist < minDistFromLink) { // Push node perpendicular to the link const dx = tx - sx; const dy = ty - sy; const len = Math.sqrt(dx * dx + dy * dy); if (len > 0) { const perpX = -dy / len; const perpY = dx / len; const push = (minDistFromLink - dist) * 0.3; n.vx = (n.vx || 0) + perpX * push; n.vy = (n.vy || 0) + perpY * push; } } } if (n.y < minY) n.y = minY; else if (n.y > maxY) n.y = maxY; } link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); node.attr('transform', d => `translate(${d.x},${d.y})`); } function simulation() { // Gentle bias left->right; organic, tree-like cohesion const fx = d3.forceX(d => depthScale(depths.get(d.id) ?? 0)).strength(0.55); const fy = d3.forceY(height / 2).strength(0.1); return d3.forceSimulation() .velocityDecay(0.35) .force('link', d3.forceLink().id(d => d.id).distance(40).strength(1.1).iterations(2)) .force('charge', d3.forceManyBody().strength(-420)) .force('collision', d3.forceCollide().radius(nodeRadius + 50).strength(0.96)) .force('x', fx) .force('y', fy); } function drag(sim) { function dragstarted(event) { if (!event.active) sim.alphaTarget(0.3).restart(); event.subject.fx = event.subject.x; event.subject.fy = event.subject.y; } function dragged(event) { event.subject.fx = event.x; event.subject.fy = event.y; } function dragended(event) { if (!event.active) sim.alphaTarget(0); event.subject.fx = null; event.subject.fy = null; } return d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended); } function computeDepthsFromEndpoints(nodes, endpoints) { const nodeIds = new Set(nodes.map(n => n.id)); const depth = new Map(); let maxDepth = 0; // Root "/" is always at depth 0 if (nodeIds.has('/')) { depth.set('/', 0); } for (const ep of endpoints) { const p = (ep && ep.path) || ''; const segs = p.split('/').filter(Boolean); // Each segment is at depth i+1 (since root "/" is at depth 0) for (let i = 0; i < segs.length; i++) { const seg = segs[i]; if (!nodeIds.has(seg)) continue; const segDepth = i + 1; const current = depth.get(seg); if (current === undefined || segDepth < current) { depth.set(seg, segDepth); } if (segDepth > maxDepth) maxDepth = segDepth; } } // Any nodes not appearing in endpoints get depth 1 (after root) or 0 if it's the root for (const id of nodeIds) { if (!depth.has(id)) { depth.set(id, id === '/' ? 0 : 1); } } return { depths: depth, maxDepth }; } } function renderERDGraph(containerSelector, erdGraph, options = {}) { const container = document.querySelector(containerSelector); container.innerHTML = ''; const width = container.clientWidth || 800; const height = Math.max(window.innerHeight - 100, container.clientHeight || 500, 600); const paddingX = 60; const paddingY = 30; const svg = d3.select(container) .append('svg') .attr('viewBox', [0, 0, width, height]) .attr('width', '100%') .attr('height', height); const g = svg.append('g'); const zoom = d3.zoom().on('zoom', (event) => { g.attr('transform', event.transform); }); svg.call(zoom); const links = erdGraph.links.map(d => Object.assign({}, d)); const nodes = erdGraph.nodes.map(d => Object.assign({}, d)); // --- Compute clusters (connected groups) --- // Build adjacency const idToIndex = new Map(nodes.map((n, i) => [n.id, i])); const parent = nodes.map((_, i) => i); const find = (x) => (parent[x] === x ? x : (parent[x] = find(parent[x]))); const unite = (a, b) => { a = find(a); b = find(b); if (a !== b) parent[b] = a; }; for (const l of links) { const si = idToIndex.get(typeof l.source === 'object' ? l.source.id : l.source); const ti = idToIndex.get(typeof l.target === 'object' ? l.target.id : l.target); if (si != null && ti != null) unite(si, ti); } // Assign group IDs const rootToNodes = new Map(); nodes.forEach((_, i) => { const r = find(i); if (!rootToNodes.has(r)) rootToNodes.set(r, []); rootToNodes.get(r).push(i); }); // Collect clusters; all singletons go into a shared "isolated" cluster const clusters = []; const isolated = []; for (const idxs of rootToNodes.values()) { if (idxs.length === 1) isolated.push(idxs[0]); else clusters.push(idxs); } if (isolated.length) clusters.push(isolated); // Compute cluster centers arranged in a grid (unbounded, scales with cluster count) const clusterCount = clusters.length || 1; const cols = Math.ceil(Math.sqrt(clusterCount)); const rows = Math.ceil(clusterCount / cols); const clusterPaddingX = 280; // spacing between clusters const clusterPaddingY = 250; // Calculate base position from viewport center, then expand outward const baseX = width / 2; const baseY = height / 2; const clusterCenters = clusters.map((_, i) => { const gx = i % cols; const gy = Math.floor(i / cols); // Center the grid around the viewport center const offsetX = (gx - (cols - 1) / 2) * clusterPaddingX; const offsetY = (gy - (rows - 1) / 2) * clusterPaddingY; return { x: baseX + offsetX, y: baseY + offsetY, }; }); // Tag nodes with cluster index const nodeToCluster = new Map(); clusters.forEach((idxs, ci) => idxs.forEach(ni => { nodeToCluster.set(ni, ci); })); nodes.forEach((n, i) => { n.__cluster = nodeToCluster.get(i) ?? 0; }); if (nodes.length === 0) { const text = g.append('text') .attr('x', width / 2) .attr('y', height / 2) .attr('text-anchor', 'middle') .attr('fill', '#666') .text('No schema components found in this OpenAPI spec'); return; } // Calculate node dimensions based on fields const nodePadding = 12; const fieldHeight = 20; const headerHeight = 40; const minWidth = 180; const maxWidth = 400; // Helper function to measure text width function measureText(text, fontSize, fontWeight = 'normal') { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); context.font = `${fontWeight} ${fontSize} Arial, sans-serif`; return context.measureText(text).width; } nodes.forEach(node => { const fieldCount = node.fields.length; // Calculate width needed for each field and the header let maxFieldWidth = 0; const headerTextWidth = measureText(node.name, '14px', 'bold'); node.fields.forEach(field => { const fieldText = `${field.required ? '*' : ' '}${field.name}: ${field.type}`; const textWidth = measureText(fieldText, '11px', 'normal'); maxFieldWidth = Math.max(maxFieldWidth, textWidth); }); // Width = max of header, longest field, or minimum, capped at maximum node.width = Math.min(maxWidth, Math.max(minWidth, Math.max(headerTextWidth, maxFieldWidth) + nodePadding * 2)); node.height = headerHeight + (fieldCount * fieldHeight) + nodePadding * 2; // Initial positions near their cluster center (no viewport constraints) const c = clusterCenters[node.__cluster] || { x: width / 2, y: height / 2 }; node.x = c.x + (Math.random() - 0.5) * 120; node.y = c.y + (Math.random() - 0.5) * 120; }); // Create link elements const link = g.append('g') .attr('stroke-width', 2) .attr('stroke', '#999') .attr('stroke-opacity', 0.6) .selectAll('line') .data(links) .enter().append('line'); // Create node groups const nodeGroup = g.append('g') .selectAll('g') .data(nodes) .enter().append('g') .attr('class', 'erd-node'); // Draw rectangle for each node const rect = nodeGroup.append('rect') .attr('x', d => d.x - d.width / 2) .attr('y', d => d.y - d.height / 2) .attr('width', d => d.width) .attr('height', d => d.height) .attr('rx', 4) .attr('fill', '#fff') .attr('stroke', '#333') .attr('stroke-width', 2); // Draw header with schema name const header = nodeGroup.append('rect') .attr('x', d => d.x - d.width / 2) .attr('y', d => d.y - d.height / 2) .attr('width', d => d.width) .attr('height', headerHeight) .attr('rx', 4) .attr('ry', 4) .attr('fill', '#4f46e5') .attr('fill-opacity', 0.1); const headerText = nodeGroup.append('text') .attr('x', d => d.x) .attr('y', d => d.y - d.height / 2 + headerHeight / 2) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') .attr('font-weight', 'bold') .attr('font-size', '14px') .attr('fill', '#333') .text(d => d.name); // Draw fields const fieldsGroup = nodeGroup.selectAll('.fields-group') .data(d => [d]) .enter() .append('g') .attr('class', 'fields-group'); fieldsGroup.each(function(node) { const fieldGroup = d3.select(this); fieldGroup.selectAll('.field') .data(node.fields) .enter() .append('text') .attr('class', 'field') .attr('x', node.x - node.width / 2 + nodePadding) .attr('y', (d, i) => node.y - node.height / 2 + headerHeight + nodePadding + (i * fieldHeight) + fieldHeight / 2) .attr('dominant-baseline', 'middle') .attr('font-size', '11px') .attr('fill', d => d.required ? '#dc2626' : '#666') .text(d => `${d.required ? '*' : ' '}${d.name}: ${d.type}`); }); // Force simulation tuned to keep entities closer and stable const simulation = d3.forceSimulation(nodes) .alpha(1) .alphaDecay(0.08) // settle faster .velocityDecay(0.6) // damp motion to prevent drifting .force('link', d3.forceLink(links).id(d => d.id) .distance(l => { // Slightly shorter distance for intra-cluster, longer for inter-cluster (rare) const sIdx = idToIndex.get(typeof l.source === 'object' ? l.source.id : l.source); const tIdx = idToIndex.get(typeof l.target === 'object' ? l.target.id : l.target); const same = sIdx != null && tIdx != null && (nodeToCluster.get(sIdx) === nodeToCluster.get(tIdx)); return same ? 140 : 220; }) .strength(0.9)) .force('charge', d3.forceManyBody().strength(-280)) // Pull nodes toward their cluster center .force('clusterX', d3.forceX(d => clusterCenters[d.__cluster]?.x || width / 2).strength(0.25)) .force('clusterY', d3.forceY(d => clusterCenters[d.__cluster]?.y || height / 2).strength(0.25)) .force('collision', d3.forceCollide().radius(d => Math.max(d.width, d.height) / 2 + 32).strength(1.0)); // Removed global center force - let clusters spread naturally // Make nodes draggable const dragHandler = d3.drag() .on('start', function(event) { if (!event.active) simulation.alphaTarget(0.3).restart(); event.subject.fx = event.subject.x; event.subject.fy = event.subject.y; }) .on('drag', function(event) { event.subject.fx = event.x; event.subject.fy = event.y; }) .on('end', function(event) { if (!event.active) simulation.alphaTarget(0); event.subject.fx = null; event.subject.fy = null; }); nodeGroup.call(dragHandler); function ticked() { // No viewport clamping - entities can spread freely // Update link positions link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); // Update node positions rect .attr('x', d => d.x - d.width / 2) .attr('y', d => d.y - d.height / 2); header .attr('x', d => d.x - d.width / 2) .attr('y', d => d.y - d.height / 2); headerText .attr('x', d => d.x) .attr('y', d => d.y - d.height / 2 + headerHeight / 2); // Update field positions nodeGroup.selectAll('.fields-group') .each(function(node) { d3.select(this).selectAll('.field') .attr('x', node.x - node.width / 2 + nodePadding) .attr('y', (d, i) => node.y - node.height / 2 + headerHeight + nodePadding + (i * fieldHeight) + fieldHeight / 2); }); } simulation.on('tick', ticked); // Hover effects nodeGroup .on('mouseenter', function(event, d) { link.classed('highlighted', l => { const sourceId = typeof l.source === 'object' ? l.source.id : l.source; const targetId = typeof l.target === 'object' ? l.target.id : l.target; return sourceId === d.id || targetId === d.id; }); link.attr('stroke-width', l => { const sourceId = typeof l.source === 'object' ? l.source.id : l.source; const targetId = typeof l.target === 'object' ? l.target.id : l.target; return (sourceId === d.id || targetId === d.id) ? 3 : 2; }); }) .on('mouseleave', function() { link.classed('highlighted', false); link.attr('stroke-width', 2); }); }