Files
specgraph/public/graph.js
2025-10-31 15:52:13 +01:00

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);
});
}