544 lines
18 KiB
JavaScript
544 lines
18 KiB
JavaScript
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 : [];
|
|
|
|
// Identify leaf nodes (last segment of endpoints) vs intermediate nodes
|
|
const leafNodeIds = new Set();
|
|
endpoints.forEach(ep => {
|
|
const path = ep.path || '';
|
|
const segments = path.split('/').filter(Boolean);
|
|
if (segments.length > 0) {
|
|
const lastSegment = segments[segments.length - 1];
|
|
leafNodeIds.add(lastSegment);
|
|
}
|
|
});
|
|
|
|
// Assign radius based on whether node is a leaf or intermediate
|
|
nodes.forEach(node => {
|
|
node.isLeaf = leafNodeIds.has(node.id);
|
|
node.radius = node.isLeaf ? 22 : 14; // Bigger for endpoints, smaller for sub-paths
|
|
});
|
|
|
|
// Visual constants
|
|
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', d => d.radius || 14)
|
|
.call(drag(simulation()));
|
|
|
|
node.append('text')
|
|
.attr('x', 0)
|
|
.attr('y', d => (d.radius || 14) + 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
|
|
// Use the maximum radius to ensure all nodes stay within bounds
|
|
const maxRadius = Math.max(...nodes.map(n => n.radius || 14));
|
|
const minY = paddingY + maxRadius;
|
|
const maxY = height - paddingY - maxRadius - labelOffset - 2;
|
|
|
|
// Push nodes away from links they don't belong to
|
|
const minDistFromLink = maxRadius + 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(d => (d.radius || 14) + 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);
|
|
});
|
|
}
|