Files
specgraph/public/graph.js
Patrick Balsiger c0706c3a2b feat: spec graph
2025-10-31 11:05:59 +01:00

158 lines
4.6 KiB
JavaScript

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