function renderGraph(containerSelector, graph, options = {}) { const container = document.querySelector(containerSelector); container.innerHTML = ''; const width = container.clientWidth || 800; const height = container.clientHeight || 500; const paddingX = 60; const paddingY = 30; const svg = d3.select(container) .append('svg') .attr('viewBox', [0, 0, width, height]) .attr('width', '100%') .attr('height', '100%'); 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); }); } const sim = simulation(); sim.nodes(nodes).on('tick', ticked); sim.force('link').links(links); function ticked() { // Clamp nodes vertically to avoid drifting too far down/up const minY = paddingY + nodeRadius; const maxY = height - paddingY - nodeRadius - labelOffset - 2; for (const n of nodes) { 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; for (const ep of endpoints) { const p = (ep && ep.path) || ''; const segs = p.split('/').filter(Boolean); for (let i = 0; i < segs.length; i++) { const seg = segs[i]; if (!nodeIds.has(seg)) continue; const current = depth.get(seg); if (current === undefined || i < current) { depth.set(seg, i); } if (i > maxDepth) maxDepth = i; } } // Any nodes not appearing in endpoints get depth 0 for (const id of nodeIds) { if (!depth.has(id)) depth.set(id, 0); } return { depths: depth, maxDepth }; } }