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