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

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