241 lines
7.8 KiB
JavaScript
241 lines
7.8 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 : [];
|
|
|
|
// 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 };
|
|
}
|
|
}
|