/** * Blueprint Graph Renderer * Custom layout algorithm designed specifically for blueprint-style graphs */ (function() { const PIN_COLORS = { exec: '#ffffff', entity: '#00a0e0', component: '#7030c0', float: '#7ecd32', int: '#1cc4c4', bool: '#8c0000', string: '#e060e0', any: '#707070' }; const HEADER_CLASSES = { event: 'event', function: 'function', pure: 'pure', flow: 'flow', math: 'math', time: 'time', debug: 'debug', variable: 'variable' }; const H_GAP = 50; // Horizontal gap between columns const V_GAP = 25; // Vertical gap between nodes const START_X = 20; const START_Y = 20; function estimateNodeSize(node) { const headerHeight = 28; // Match CSS: min-height 28px const pinRowHeight = 22; // Match CSS: pin row ~22px const bodyPadding = 12; // Top + bottom padding // Count all pins in body (each pin is its own row now) const inputExecCount = node.inputs ? node.inputs.filter(p => p.type === 'exec').length : 0; const inputDataCount = node.inputs ? node.inputs.filter(p => p.type !== 'exec').length : 0; const outputExecCount = node.outputs ? node.outputs.filter(p => p.type === 'exec' && !p.inHeader).length : 0; const outputDataCount = node.outputs ? node.outputs.filter(p => p.type !== 'exec' && !p.inHeader).length : 0; const totalPins = inputExecCount + inputDataCount + outputExecCount + outputDataCount; // Calculate height: header + body padding + all pin rows const bodyHeight = totalPins > 0 ? bodyPadding + (totalPins * pinRowHeight) : 0; const height = headerHeight + bodyHeight; // Calculate width based on content let maxLabelLen = node.title.length; if (node.inputs) { node.inputs.forEach(p => { const len = (p.label || '').length + (p.value ? String(p.value).length + 3 : 0); maxLabelLen = Math.max(maxLabelLen, len); }); } if (node.outputs) { node.outputs.forEach(p => { maxLabelLen = Math.max(maxLabelLen, (p.label || '').length); }); } const width = Math.max(110, Math.min(170, maxLabelLen * 8 + 40)); return { width, height }; } /** * Smart blueprint layout algorithm * * Uses weighted graph analysis: * - All connections matter (exec has higher weight) * - Topological sort for X ordering * - Force-directed optimization for Y positions */ function autoLayout(graphData, maxWidth) { const nodes = graphData.nodes; const connections = graphData.connections; if (nodes.length === 0) return { positions: {}, sizes: {} }; // Calculate node sizes const nodeSizes = {}; nodes.forEach(n => { nodeSizes[n.id] = estimateNodeSize(n); }); // Build maps const pinToNode = {}; const nodeById = {}; nodes.forEach(n => { nodeById[n.id] = n; (n.inputs || []).forEach(p => { pinToNode[p.id] = n.id; }); (n.outputs || []).forEach(p => { pinToNode[p.id] = n.id; }); }); // Build weighted adjacency: outgoing[nodeId] = [{to, weight}] const outgoing = {}; const incoming = {}; nodes.forEach(n => { outgoing[n.id] = []; incoming[n.id] = []; }); connections.forEach(c => { const from = pinToNode[c.from]; const to = pinToNode[c.to]; if (!from || !to || from === to) return; const weight = c.type === 'exec' ? 3 : 1; outgoing[from].push({ to, weight }); incoming[to].push({ from, weight }); }); // Calculate node "depth" using weighted longest path const nodeDepth = {}; const visited = new Set(); const inProcess = new Set(); function calcDepth(nodeId) { if (visited.has(nodeId)) return nodeDepth[nodeId]; if (inProcess.has(nodeId)) return 0; // Cycle detected inProcess.add(nodeId); let maxPrevDepth = -1; incoming[nodeId].forEach(({ from, weight }) => { const prevDepth = calcDepth(from); maxPrevDepth = Math.max(maxPrevDepth, prevDepth); }); inProcess.delete(nodeId); visited.add(nodeId); nodeDepth[nodeId] = maxPrevDepth + 1; return nodeDepth[nodeId]; } // Calculate depth for all nodes nodes.forEach(n => calcDepth(n.id)); // Group nodes by depth (column) const columnNodes = {}; nodes.forEach(n => { const depth = nodeDepth[n.id]; if (!columnNodes[depth]) columnNodes[depth] = []; columnNodes[depth].push(n.id); }); // Sort columns const sortedColumns = Object.keys(columnNodes).map(Number).sort((a, b) => a - b); // Calculate X positions const columnX = {}; let currentX = START_X; sortedColumns.forEach(col => { columnX[col] = currentX; let maxW = 0; columnNodes[col].forEach(id => { maxW = Math.max(maxW, nodeSizes[id].width); }); currentX += maxW + H_GAP; }); // Initialize Y positions - simple stacking first const positions = {}; sortedColumns.forEach(col => { let y = START_Y; columnNodes[col].forEach(id => { positions[id] = { x: columnX[col], y }; y += nodeSizes[id].height + V_GAP; }); }); // Force-directed optimization for Y positions (few iterations) for (let iter = 0; iter < 5; iter++) { const forces = {}; nodes.forEach(n => { forces[n.id] = 0; }); // Calculate forces from connections connections.forEach(c => { const from = pinToNode[c.from]; const to = pinToNode[c.to]; if (!from || !to || from === to) return; const weight = c.type === 'exec' ? 2 : 1; const fromY = positions[from].y + nodeSizes[from].height / 2; const toY = positions[to].y + nodeSizes[to].height / 2; const diff = toY - fromY; // Pull nodes toward each other forces[from] += diff * 0.1 * weight; forces[to] -= diff * 0.1 * weight; }); // Apply forces nodes.forEach(n => { positions[n.id].y += forces[n.id]; positions[n.id].y = Math.max(START_Y, positions[n.id].y); }); // Resolve overlaps within columns sortedColumns.forEach(col => { const nodesInCol = columnNodes[col]; nodesInCol.sort((a, b) => positions[a].y - positions[b].y); for (let i = 1; i < nodesInCol.length; i++) { const prevId = nodesInCol[i - 1]; const currId = nodesInCol[i]; const minY = positions[prevId].y + nodeSizes[prevId].height + V_GAP; if (positions[currId].y < minY) { positions[currId].y = minY; } } }); } return { positions, sizes: nodeSizes }; } function renderPinSvg(type, filled = true) { const color = PIN_COLORS[type] || PIN_COLORS.any; if (type === 'exec') { return ``; } return ``; } function renderNode(node, position, size) { const isEvent = node.category === 'event'; const headerClass = HEADER_CLASSES[node.category] || 'function'; let html = `