feat: form and proxy call

This commit is contained in:
Patrick Balsiger
2025-10-31 11:27:44 +01:00
parent c0706c3a2b
commit e19548a205
7 changed files with 647 additions and 42 deletions

View File

@@ -2,7 +2,8 @@ function renderGraph(containerSelector, graph, options = {}) {
const container = document.querySelector(containerSelector);
container.innerHTML = '';
const width = container.clientWidth || 800;
const height = container.clientHeight || 500;
// 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;
@@ -10,7 +11,7 @@ function renderGraph(containerSelector, graph, options = {}) {
.append('svg')
.attr('viewBox', [0, 0, width, height])
.attr('width', '100%')
.attr('height', '100%');
.attr('height', height);
const g = svg.append('g');
@@ -71,15 +72,87 @@ function renderGraph(containerSelector, graph, options = {}) {
});
}
// 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;
}
@@ -135,22 +208,32 @@ function renderGraph(containerSelector, graph, options = {}) {
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 || i < current) {
depth.set(seg, i);
if (current === undefined || segDepth < current) {
depth.set(seg, segDepth);
}
if (i > maxDepth) maxDepth = i;
if (segDepth > maxDepth) maxDepth = segDepth;
}
}
// Any nodes not appearing in endpoints get depth 0
// 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, 0);
if (!depth.has(id)) {
depth.set(id, id === '/' ? 0 : 1);
}
}
return { depths: depth, maxDepth };
}