154 lines
6.0 KiB
JavaScript
154 lines
6.0 KiB
JavaScript
/**
|
|
* Custom recursive tree layout — Left to Right.
|
|
*
|
|
* Each node is centred vertically relative to its children's block.
|
|
* Siblings are packed tightly; different root trees are separated by ROOT_GAP.
|
|
* Lambda handlers are placed in a compact grid below the main tree.
|
|
*
|
|
* This avoids Dagre's global ranking which puts all siblings in a single
|
|
* long tower regardless of the number of nodes.
|
|
*/
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
const RANK_GAP = 60 // horizontal gap between a node's right edge and its children's left edge
|
|
const SIBLING_GAP = 12 // vertical gap between siblings that belong to the same parent
|
|
const ROOT_GAP = 48 // vertical gap between independent subtrees (different roots)
|
|
const MARGIN_X = 60
|
|
const MARGIN_Y = 60
|
|
|
|
const ABSTRACT_W = 220
|
|
const ABSTRACT_H = 50
|
|
const CONCRETE_W = 260
|
|
const LAMBDA_W = 230
|
|
const LAMBDA_H = 80
|
|
|
|
// ── Node sizing ──────────────────────────────────────────────────────────────
|
|
function nodeWidth(node) {
|
|
return node.data?.isAbstract ? ABSTRACT_W : CONCRETE_W
|
|
}
|
|
|
|
function nodeHeight(node) {
|
|
if (node.data?.isAbstract) return ABSTRACT_H
|
|
const routes = node.data?.routes?.length || 1
|
|
const hasMw = (node.data?.middleware?.length || 0) > 0
|
|
// header(44) + divider(9) + routes*22 + [mw row 22] + padding(20)
|
|
return 44 + 9 + routes * 22 + (hasMw ? 22 : 0) + 20
|
|
}
|
|
|
|
// ── Subtree height (recursive) ────────────────────────────────────────────────
|
|
function subtreeHeight(nodeId, childrenMap, nodeById) {
|
|
const kids = childrenMap.get(nodeId) || []
|
|
const selfH = nodeHeight(nodeById[nodeId])
|
|
if (!kids.length) return selfH
|
|
|
|
const kidsH = kids.reduce((acc, id, i) => {
|
|
return acc + subtreeHeight(id, childrenMap, nodeById) + (i > 0 ? SIBLING_GAP : 0)
|
|
}, 0)
|
|
|
|
return Math.max(selfH, kidsH)
|
|
}
|
|
|
|
// ── Place a node and its subtree ──────────────────────────────────────────────
|
|
function placeNode(nodeId, x, y, childrenMap, nodeById, positions) {
|
|
const node = nodeById[nodeId]
|
|
const kids = childrenMap.get(nodeId) || []
|
|
const selfH = nodeHeight(node)
|
|
const selfW = nodeWidth(node)
|
|
const totalH = subtreeHeight(nodeId, childrenMap, nodeById)
|
|
|
|
// Centre this node within the height its subtree occupies
|
|
positions.set(nodeId, { x, y: y + (totalH - selfH) / 2 })
|
|
|
|
if (kids.length) {
|
|
const childX = x + selfW + RANK_GAP
|
|
let childY = y
|
|
kids.forEach(kidId => {
|
|
const kidH = subtreeHeight(kidId, childrenMap, nodeById)
|
|
placeNode(kidId, childX, childY, childrenMap, nodeById, positions)
|
|
childY += kidH + SIBLING_GAP
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Main export ───────────────────────────────────────────────────────────────
|
|
export function layoutGraph(nodes, edges) {
|
|
// Separate lambdas from class-based handlers
|
|
const lambdas = nodes.filter(n => n.data?.isLambda)
|
|
const regulars = nodes.filter(n => !n.data?.isLambda)
|
|
|
|
const nodeById = Object.fromEntries(regulars.map(n => [n.id, n]))
|
|
const childrenMap = new Map() // parentId → [childId, ...]
|
|
const parentSet = new Set() // ids that have a parent
|
|
|
|
edges.forEach(e => {
|
|
if (e.edgeType !== 'extends') return
|
|
if (!childrenMap.has(e.source)) childrenMap.set(e.source, [])
|
|
childrenMap.get(e.source).push(e.target)
|
|
parentSet.add(e.target)
|
|
})
|
|
|
|
// Roots = regular nodes with no incoming extends edge
|
|
const roots = regulars.filter(n => !parentSet.has(n.id))
|
|
|
|
// Layout each root subtree
|
|
const positions = new Map()
|
|
let curY = MARGIN_Y
|
|
|
|
roots.forEach(root => {
|
|
const treeH = subtreeHeight(root.id, childrenMap, nodeById)
|
|
placeNode(root.id, MARGIN_X, curY, childrenMap, nodeById, positions)
|
|
curY += treeH + ROOT_GAP
|
|
})
|
|
|
|
// Apply positions
|
|
let maxX = -Infinity
|
|
let maxY = -Infinity
|
|
let minY = Infinity
|
|
|
|
const layoutedNodes = regulars.map(node => {
|
|
const pos = positions.get(node.id) || { x: MARGIN_X, y: MARGIN_Y }
|
|
const r = pos.x + nodeWidth(node)
|
|
const b = pos.y + nodeHeight(node)
|
|
if (r > maxX) maxX = r
|
|
if (b > maxY) maxY = b
|
|
if (pos.y < minY) minY = pos.y
|
|
return { ...node, position: pos }
|
|
})
|
|
|
|
// ── Lambda grid — centred below the main tree ────────────────────────────
|
|
if (lambdas.length) {
|
|
const treeWidth = maxX - MARGIN_X
|
|
const cols = Math.min(4, Math.max(2, Math.ceil(Math.sqrt(lambdas.length))))
|
|
const colWidth = LAMBDA_W + 30
|
|
const rowHeight = LAMBDA_H + 16
|
|
const gridWidth = cols * colWidth - 30
|
|
const gridStartX = MARGIN_X + Math.max(0, (treeWidth - gridWidth) / 2)
|
|
const gridStartY = maxY + 80
|
|
|
|
lambdas.forEach((node, i) => {
|
|
layoutedNodes.push({
|
|
...node,
|
|
position: {
|
|
x: gridStartX + (i % cols) * colWidth,
|
|
y: gridStartY + Math.floor(i / cols) * rowHeight,
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
// ── Edges ────────────────────────────────────────────────────────────────
|
|
const layoutedEdges = edges
|
|
.filter(e => e.edgeType === 'extends')
|
|
.map((edge, idx) => ({
|
|
...edge,
|
|
id: edge.id || `ext-${idx}`,
|
|
type: 'bezier',
|
|
pathOptions: { curvature: 0.35 },
|
|
style: { stroke: '#2e3347', strokeWidth: 1.5 },
|
|
markerEnd: { type: 'arrowclosed', width: 11, height: 11, color: '#2e3347' },
|
|
animated: false,
|
|
}))
|
|
|
|
return { nodes: layoutedNodes, edges: layoutedEdges }
|
|
}
|